Note: This repository was previously known as
mermaid-playground.nvim. It has been renamed and rewritten to support full Markdown preview alongside first-class Mermaid diagram support.
Live Markdown preview for Neovim with first-class Mermaid diagram support.
- Renders your entire
.mdfile in the browser — headings, tables, code blocks, everything - Relative images work —
next to your.mdfile renders in the preview - Mermaid diagrams render inline as interactive SVGs (click to expand, zoom, pan, export)
- Instant updates via Server-Sent Events (no polling) with scroll sync — browser follows your cursor
- LaTeX math — inline
$...$and display$$...$$rendered via KaTeX - Syntax highlighting for code blocks (highlight.js)
- Dark / Light theme toggle with colored heading accents
- Optional Rust-powered rendering — use
mermaid-rs-rendererfor ~400x faster mermaid diagrams - Zero external dependencies — no npm, no Node.js, just Neovim + your browser
- Powered by
live-server.nvim(pure Lua HTTP server)
{
"selimacerbas/markdown-preview.nvim",
dependencies = { "selimacerbas/live-server.nvim" },
config = function()
require("markdown_preview").setup({
-- all optional; sane defaults shown
instance_mode = "takeover", -- "takeover" (one tab) or "multi" (tab per instance)
port = 0, -- 0 = auto (8421 for takeover, OS-assigned for multi)
open_browser = true,
default_theme = "dark", -- "dark" or "light"; initial preview theme
debounce_ms = 300,
})
end,
}No prereqs. No npm install. Just install and go.
Open any Markdown file, then:
- Start preview:
:MarkdownPreview - Edit freely — the browser updates instantly as you type
- Force refresh:
:MarkdownPreviewRefresh - Stop:
:MarkdownPreviewStop
The first start opens your browser. Subsequent updates reuse the same tab.
.mmd / .mermaid files are fully supported — the entire file is rendered as a diagram.
For other non-markdown files, place your cursor inside a fenced ```mermaid block — the plugin extracts and previews just that diagram.
| Command | Description |
|---|---|
:MarkdownPreview |
Start preview |
:MarkdownPreviewRefresh |
Force refresh |
:MarkdownPreviewStop |
Stop preview |
No keymaps are set by default — map them however you like. Suggested:
vim.keymap.set("n", "<leader>mps", "<cmd>MarkdownPreview<cr>", { desc = "Markdown: Start preview" })
vim.keymap.set("n", "<leader>mpS", "<cmd>MarkdownPreviewStop<cr>", { desc = "Markdown: Stop preview" })
vim.keymap.set("n", "<leader>mpr", "<cmd>MarkdownPreviewRefresh<cr>", { desc = "Markdown: Refresh preview" })The preview opens a polished browser app with:
- Full Markdown rendering — GitHub-flavored styling with colored heading borders, lists, tables, blockquotes, code, images, links, horizontal rules
- Syntax-highlighted code blocks — powered by highlight.js, with language badges
- Interactive Mermaid diagrams — rendered inline as SVGs:
- Hover a diagram to reveal the expand button
- Click to open a fullscreen overlay with zoom, pan, fit-to-width/height, and SVG export
- Dark / Light theme toggle (sun/moon icon in header)
- Live connection indicator — green dot when SSE is connected
- Per-diagram error handling — if one mermaid block is invalid, only that block shows an error; the rest of the page renders fine
- LaTeX math rendering —
$E = mc^2$inline and$$\int_0^\infty$$display math via KaTeX, plus\begin{equation}environments - Scroll sync — browser follows your cursor position with line-level precision
- Iconify auto-detection — icon packs like
logos:google-cloudare loaded on demand
require("markdown_preview").setup({
instance_mode = "takeover", -- "takeover" or "multi" (see below)
port = 0, -- 0 = auto (8421 for takeover, OS-assigned for multi)
host = "127.0.0.1", -- bind address; "0.0.0.0" for network access (see Remote access)
open_browser = true, -- auto-open browser on start
-- nil = system default browser
-- string = browser name ("Firefox") or binary ("google-chrome")
-- table = full command, URL appended ({ "google-chrome", "--incognito" })
-- On macOS, string values are passed via `open -a <name>`.
browser = nil,
content_name = "content.md", -- workspace content file
index_name = "index.html", -- workspace HTML file
custom_css = "", -- CSS file layered over bundled styles (~ and $VARS ok; "" = off)
workspace_dir = nil, -- nil = auto (shared for takeover, per-buffer for multi)
overwrite_index_on_start = true, -- copy plugin's index.html on every start
auto_refresh = true, -- auto-update on buffer changes
auto_refresh_events = { -- which events trigger refresh
"InsertLeave", "TextChanged", "TextChangedI", "BufWritePost"
},
debounce_ms = 300, -- debounce interval
notify_on_refresh = false, -- show notification on refresh
mermaid_renderer = "js", -- "js" (browser mermaid.js) or "rust" (mmdr CLI, ~400x faster)
default_theme = "dark", -- "dark" or "light"; initial preview theme (toggleable in browser)
yaml_mode = "panel", -- front matter: "panel" (collapsible above preview), "hide", or "raw"
allow_raw_html = true, -- render raw HTML in markdown; set false for untrusted files (see Security)
scroll_sync = true, -- browser follows cursor position
-- Fraction (0–1): vertical position of the final line when scrolled to end.
-- 0.5 = middle of viewport (default), 1.0 = bottom edge (no extra space)
bottom_padding = 0.5,
hooks = {
on_start = nil, -- fun(url: string)|nil — called after preview starts
on_stop = nil, -- fun()|nil — called after preview stops
},
})Lifecycle callbacks that run when the preview starts or stops. Use them for notifications, logging, or triggering other actions.
require("markdown_preview").setup({
hooks = {
on_start = function(url)
vim.notify("Preview started: " .. url, vim.log.levels.INFO)
end,
on_stop = function()
vim.notify("Preview stopped", vim.log.levels.INFO)
end,
},
})on_start(url)— called after the server is ready, before the browser opens. Receives the preview URL as a string.on_stop()— called after the server is stopped and all cleanup is done.
If you're running Neovim on a remote machine over SSH and want to view the preview on your local machine, bind to all interfaces and use on_start to print the URL:
require("markdown_preview").setup({
host = "0.0.0.0",
open_browser = false,
hooks = {
on_start = function(url)
vim.notify("Markdown Preview: " .. url, vim.log.levels.INFO)
end,
},
})The notification will show the full URL including the auth token (e.g. http://10.0.0.5:8421/?t=...). Most terminals support Ctrl+Shift+click on the URL to open it directly in your local browser.
Security notes for network binding
- With a non-loopback
host, the tokenized URL is required for everything, including the page itself — requests without?t=<token>get 401. Peers on your network cannot read your buffer without the URL.- Traffic is plain, unencrypted HTTP. Anyone who obtains the URL (or can sniff the local network) can read the previewed buffer while the preview runs.
- Takeover mode supports
host = "127.0.0.1"or"0.0.0.0"only. To bind a specific interface, useinstance_mode = "multi".- Zero-config alternative: keep the default loopback bind and tunnel instead —
ssh -L 8421:localhost:8421 <remote>— then open the URL printed byon_startlocally, replacing the host with127.0.0.1. Nothing is exposed to the network, and traffic is encrypted by SSH.
Takeover (default) — all Neovim instances share a single workspace and browser tab. The first instance to run :MarkdownPreview becomes the primary (starts the server on port 8421). Subsequent instances become secondaries — they write content to the shared workspace, and the server's file watcher pushes a reload to the browser. Scroll sync works across instances via HTTP event injection.
Multi — each instance gets its own server on an OS-assigned port and its own browser tab. Use this for side-by-side previews of different files.
require("markdown_preview").setup({ instance_mode = "multi" })graph LR
A[Neovim Buffer] -->|write| B[content.md]
A -.->|optional: mmdr| B
B -->|fs watch| C[live-server.nvim]
C -->|SSE| D[Browser]
D --> E[markdown-it]
D --> F[mermaid.js]
D --> G[highlight.js]
E --> H[Rendered Preview]
F --> H
G --> H
Neovim buffer
|
| (autocmd: debounced write)
v
workspace/content.md
|
| (live-server.nvim detects change)
v
SSE event --> Browser
|
| markdown-it --> HTML
| mermaid.js --> inline SVG diagrams
| highlight.js --> syntax highlighting
| morphdom --> efficient DOM diffing
v
Rendered preview (scroll preserved, no flicker)
- Rust renderer (
mermaid_renderer = "rust"): mermaid fences are pre-rendered to SVG via themmdrCLI before writing tocontent.md— the browser receives ready-made SVGs with no mermaid.js overhead. Failed blocks fall back to browser-side rendering automatically. - Markdown files: The entire buffer is written to
content.md - Mermaid files (
.mmd,.mermaid): The entire buffer is wrapped in a mermaid code fence - Other files: The mermaid block under the cursor is extracted (via Tree-sitter or regex fallback) and wrapped in a code fence
- SSE (Server-Sent Events) from
live-server.nvimpush updates instantly — no polling - morphdom diffs the DOM efficiently, preserving scroll position and interactive state
- Takeover mode shares a single workspace (
~/.cache/nvim/markdown-preview/shared/) and browser tab across all Neovim instances via a lock file - Multi mode uses per-buffer workspaces under
~/.cache/nvim/markdown-preview/<hash>/with independent servers
- Neovim 0.9+
- live-server.nvim — pure Lua HTTP server (no npm)
- Tree-sitter with the Markdown parser (recommended for mermaid block extraction)
- mermaid-rs-renderer (optional) —
cargo install mermaid-rs-rendererfor ~400x faster mermaid rendering. Setmermaid_renderer = "rust"in config to enable.
Browser-side libraries are loaded from CDN (cached by your browser):
- markdown-it — Markdown parser
- KaTeX + markdown-it-texmath — LaTeX math rendering
- Mermaid — diagram engine
- highlight.js — syntax highlighting
- morphdom — DOM diffing
- Local by default. The preview server binds to
127.0.0.1. Your buffer content (content.md), the SSE stream, and the event-injection endpoint all require a per-session 128-bit token; with a non-loopbackhost, the preview page itself requires it too (see Remote access above). - Raw HTML is rendered by default (GitHub-like). HTML embedded in markdown runs inside the preview page, so if you preview markdown you didn't write, set
allow_raw_html = falseto have it rendered as plain text instead. - Browser libraries load from CDNs (jsdelivr/unpkg, see Dependencies). Nothing from your machine is sent to them, but rendering requires internet access. Vendoring the assets locally is planned (#27).
custom_cssfiles are inlined into the preview page verbatim. Point it only at files you trust.- Relative images are served from the previewed file's directory. The token-gated asset route can serve any file at or below that directory (not just images), so on a non-loopback
hostanyone holding the tokenized URL could request other files there (.env,secrets.txt, …). Keep sensitive files out of the directory tree you preview from when binding to the network, or prefer an SSH tunnel. - The takeover-mode lock file (which contains the session token) is written with mode
0600.
WSL: browser doesn't open, or preview unreachable from Windows
- The plugin tries
wslview,explorer.exe, thenpowershell.exeto open your Windows browser. Installing wslu (sudo apt install wslu) is the most reliable option; you can also setbrowser = "wslview"explicitly. - If no launcher works, a notification shows the preview URL — open it manually in your Windows browser.
- If
http://127.0.0.1:8421/is unreachable from Windows, WSL2's localhost forwarding has likely broken (common after sleep, hibernate, or VPN changes). Runwsl --shutdownfrom PowerShell and reopen WSL. Alternatively bind the server to all interfaces (host = "0.0.0.0") and open the URL printed byhooks.on_start(see Remote access). explorer.exe/powershell.exerequire Windows interop; check/etc/wsl.conffor[interop] enabled=falseorappendWindowsPath=false.
Images don't show
- Relative paths (
pic.png,images/pic.png) are served from the directory of the file being previewed, via a token-gated asset route. Paths that resolve outside that directory (e.g.../shared/pic.png) are rejected for containment; keep referenced images at or below the markdown file's directory. - Absolute filesystem paths (
/home/me/pic.png) are not supported; http(s) URLs load as usual.
Browser shows nothing or "Loading..."
- Make sure
live-server.nvimis installed and loadable::lua require("live_server") - Check the port isn't in use: change
portin config
Mermaid diagram not rendering
- The diagram syntax must be valid Mermaid — check the error chip on the diagram block
- Invalid diagrams show the last good render + error message
Port conflict
- In takeover mode, stop the other instance first or change the port:
port = 9999 - In multi mode, ports are auto-assigned — conflicts shouldn't happen
Stale lock file (takeover mode)
- If Neovim crashes, the lock file may persist. The next
:MarkdownPreviewdetects the dead server and automatically takes over
markdown-preview.nvim/
├─ plugin/markdown-preview.lua -- commands
├─ lua/markdown_preview/
│ ├─ init.lua -- main logic (server, refresh, workspace, instance modes)
│ ├─ util.lua -- fs helpers, workspace resolution
│ ├─ ts.lua -- Tree-sitter mermaid extractor + fallback
│ ├─ lock.lua -- lock file management (takeover mode coordination)
│ └─ remote.lua -- HTTP event injection (secondary scroll sync)
└─ assets/
└─ index.html -- browser preview app
- Mermaid for the diagram engine
- Iconify for icon packs
- markdown-it for Markdown parsing
- highlight.js for syntax highlighting
- morphdom for efficient DOM updates
PRs and ideas welcome!