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",
-- a live-server.nvim checkout under another dir name needs its spec to
-- say name = "live-server.nvim", or lazy.nvim clones upstream beside it
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); multi mode serves a set directory whole, so keep nothing else in it: on a network bind its other files need no token
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; false is meant to render it as text and is being hardened (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 the plugin writes, including the page itself: requests without?t=<token>get 401. Peers on your network cannot read your buffer without the URL. Aworkspace_dirset in multi mode is served whole, so any other file you keep there is readable without it.- 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.10+ (every release needed it; from this one the plugin says so at load instead of failing at first use)
- 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. A per-session 128-bit token gates five surfaces: your buffer content (content.md), theasset_rootsidecar, the SSE stream, the event-injection endpoint and the asset route; with a non-loopbackhost, the preview page itself requires it too (see Remote access above). SECURITY.md says what the token keeps out on each bind. - Raw HTML is rendered by default (GitHub-like). HTML embedded in markdown runs inside the preview page. With
allow_raw_html = falsethe preview is meant to render embedded HTML as text, and that switch is being hardened, so a file you do not trust is previewed at your own risk today. - Browser libraries load from CDNs (jsdelivr/unpkg, see Dependencies). Rendering requires internet access, and a compromised script served from a CDN would run in the page that holds the token (see SECURITY.md). 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). A previewed file's raw HTML runs in a page that holds the token, so on any bind it can read every file under the source file's directory through the asset route (
.env,secrets.txt, …); on a non-loopbackhostanyone holding the tokenized URL can too. Keep sensitive files out of the directory tree you preview from, and prefer an SSH tunnel to a network bind. - 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)
│ ├─ floor.lua -- the Neovim requirement and its message
│ ├─ 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
├─ lazy.lua -- the spec lazy.nvim reads: live-server.nvim as a dependency
└─ tests/ -- the headless suites, their harness and runner, and browser/ (the browser smoke test)
- 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: CONTRIBUTING.md names the commands CI runs and the commit rules, and SECURITY.md says how to report a vulnerability privately.