mdview.nvim · View & render · vimdoc
:help mdview
Browser-based Markdown preview for Neovim
doc/mdview.txt — rendered from the plugin's own vimdoc
*mdview.txt* Browser-based Markdown preview for Neovim *mdview.nvim*
CONTENTS
1. Introduction ............................................. |mdview-intro| 2. Requirements ...................................... |mdview-requirements| 3. Installation ...................................... |mdview-installation| 4. Configuration ........................................... |mdview-config| 5. Commands .............................................. |mdview-commands| 6. Autocommands .......................................... |mdview-autocmds| 7. Keymaps ................................................ |mdview-keymaps| 8. Health check ............................................ |mdview-health| 9. Architecture ...................................... |mdview-architecture| 10. Security model ........................................ |mdview-security| 11. Background & standalone ........................... |mdview-standalone|
1. INTRODUCTION
mdview.nvim renders the current Markdown buffer live in a browser tab.
A small Go relay process streams raw buffer text to the browser over
WebSocket. Rendering and HTML sanitization happen entirely client-side, in a
Rust module compiled to WebAssembly (comrak for Markdown -> HTML, ammonia for
allowlist-based sanitization). The relay never touches HTML — it only ever
transports the raw text and the already-sanitized-in-the-browser output never
leaves the browser again. See |mdview-security| for the full threat model.
No Node.js, Go or Rust toolchain is required to run the plugin. The relay
binary and the browser client bundle are fetched once from GitHub Releases on
first use and cached under |stdpath('data')|, the same pattern mason.nvim and
nvim-treesitter use for external binaries.
2. REQUIREMENTS
- Neovim 0.9 or later - lib.nvim (https://github.com/StefanBartl/lib.nvim) — hard dependency, used for cross-platform helpers (path normalization, OS detection, user command / autocmd registration wrappers) -curlon PATH — downloads the relay binary and client bundle -taron PATH — extracts the downloaded client bundle (bundled with Windows 10 1803+, macOS and virtually every Linux distro) Run |:checkhealth-mdview| to verify all of the above on your system.
3. INSTALLATION
lazy.nvim, lazy-loaded on markdown files or the plugin's own commands (recommended):
{
"StefanBartl/mdview.nvim",
dependencies = { "StefanBartl/lib.nvim" },
ft = { "markdown" },
cmd = { "MDView" },
config = function()
require("mdview").setup()
end,
}
lazy.nvim, eager (loads at startup instead of on demand):
{
"StefanBartl/mdview.nvim",
dependencies = { "StefanBartl/lib.nvim" },
lazy = false,
config = function()
require("mdview").setup()
end,
}
packer.nvim:
use {
"StefanBartl/mdview.nvim",
requires = { "StefanBartl/lib.nvim" },
ft = { "markdown" },
cmd = { "MDView" },
config = function()
require("mdview").setup()
end,
}
No external toolchain is required by end users — the relay binary and client bundle download automatically the first time |:MDView-start| runs.
4. CONFIGURATION
*mdview.setup()* Callrequire("mdview").setup({opts})from your plugin manager's config function. All defaults live in a single typed table in lua/mdview/config/DEFAULTS.lua;setup()deep-merges your overrides into it in place, so a partial nested override (e.g. onlybrowser.browser) never wipes out the rest of that sub-table's defaults. Defaults:
{
ft_pattern = { "*.md", "*.markdown", "*.mdx" },
any_file = false, -- preview any normal text buffer, not just Markdown
server_port = 43219, -- preferred port for the relay server
server_cwd = nil, -- optional explicit cwd for the relay process
dev_server_port = 43220, -- Vite dev server port (dev workflow only)
dev_local = true,
debug = false, -- echo relay stdout/stderr into Neovim
log_buffer_name = "mdview://logs",
file_log = false, -- opt in to writing the relay log to a file
file_log_path = nil, -- default: stdpath('log')/mdview/relay-<ts>.log
debug_plugin = false, -- plugin-internal debug notifications
debug_preview = false, -- per-push debug notifications (noisy)
scroll_sync = true, -- nvim-to-browser cursor-follow scroll
scroll_sync_throttle_ms = 150, -- min. time between scroll-position pings
breadcrumbs = true, -- record session breadcrumbs
-- (:MDView breadcrumbs)
sync_checkboxes = true, -- ticking a task-list checkbox in the
-- preview writes back to the source
sync_fields = true, -- editing an <input name>/<textarea name>
-- in the preview writes its value back
open_preview_tab = false, -- :MDView start opens an nvim tab, not a browser
browser = {
open_mode = "default", -- "default" (tab in your browser)
-- | "isolated" (own window, auto-close)
behavior = "reuse", -- on buffer switch: "reuse" (one tab
-- follows) | "new_tab" | "manual"
theme = "github", -- github|dark-dimmed|plain|tokyonight|
-- catppuccin (optionally -light/-dark)
highlighter = "hljs", -- code highlighter: hljs|shiki|nvim|none
external_links = "new_tab", -- external links: "new_tab" | "same_tab"
cursor_marker = "line", -- "line"|"caret"|"section"|"off"
selection_sync = false, -- mirror the visual selection (v/V/C-v)
zoom = 1.0, -- preview font-size zoom (:MDView zoom)
overlays = { -- preview overlays (:MDView overlay)
toc = false, -- floating outline, current section lit
},
focus = "browser", -- "browser" | "nvim" (keep editor focus)
autodetect_browser = true, -- isolated mode only
browser = "", -- friendly name, e.g. "firefox" (isolated)
browser_cmd = "", -- absolute path (isolated mode only)
browser_autoclose = true, -- :MDView stop closes the tab (isolated only)
browser_autostart = true, -- open the browser on :MDView start
browser_args = nil, -- extra CLI args (isolated mode only)
open_url = nil, -- static URL override (advanced)
require_display = true, -- skip browser without GUI/DISPLAY
stop_on_browser_exit = true, -- browser closed => :MDView stop (isolated)
},
start = {
push_strategy = "launcher", -- "launcher" | "try_push"
try_push_opts = nil,
wait_timeout_ms = nil,
},
install = {
repo = "StefanBartl/mdview.nvim", -- override to use a fork
version = "v0.3.0", -- pin a specific release tag
},
standalone = {
binary_path = nil, -- relay used by :MDView standalone (nil = installed one)
},
experimental = {
webtransport = false, -- opt-in WebTransport/HTTP3 (falls back to WS)
line_diff = false, -- opt-in: send only changed lines per edit
click_navigate = true, -- default on: relative links open the file in nvim
reverse_scroll = false, -- opt-in: scrolling the preview moves nvim's cursor
},
}
Example override:
require("mdview").setup({
server_port = 43219,
browser = { browser = "firefox", browser_autostart = false },
start = { push_strategy = "try_push" },
install = { repo = "your-fork/mdview.nvim", version = "v0.3.0" },
})
Field reference
ft_pattern
Filetype/glob patterns mdview's autocommands attach to. Globs, not bare extensions:*.markdown, not.markdown.
any_file
Preview any normal text buffer, not just Markdown. Widensft_patternto{ "*" }(overriding a hand-set one) and renders non-Markdown files as a syntax-highlighted read-only code view instead of through the Markdown renderer; scroll sync falls back to proportional, so there is no cursor line bar. Terminal, help, quickfix, scratch, binary and unnamed buffers stay excluded. Off by default. Wasexperimental.any_filebefore the 2026-08-30 release check; the old key still works.
server_port / server_cwd
Preferred port and optional working directory for the spawned
mdview-server relay process. The relay itself falls back to the next free
port if server_port is taken.
dev_server_port
Vite dev server port; only relevant to the contributor dev workflow (see README.md's Development section), not to end users.
scroll_sync
Send the cursor's line + total line count to the browser preview on
CursorMoved/CursorMovedI, so it scrolls to follow (nvim-to-browser only;
see |mdview-autocmds|). Set to false to disable.
scroll_sync_throttle_ms
Minimum time between scroll-position pings sent to the relay.
sync_checkboxes
Whentrue(default), ticking a GFM task-list checkbox (- [ ]/- [x]) in the preview writes the change back to the source: |:MDView-standalone| rewrites the file in the relay, |:MDView-start| edits the buffer (polled via the browser->Neovim bridge, since Neovim has no WebSocket client). Only the one marker character changes — indentation, bullet style and text are preserved. Setfalseto render checkboxes read-only and, in start mode, stop that poll.
sync_fields
Whentrue(default), editing a raw-HTML text field written in the source with aname(<input type="text" name="x">or<textarea name="y">) and committing it (blur/Enter) writes its value back to the source. Raw HTML has no source position, so the field is located by itsnameattribute rather than by line —namemust be unique per document, double-quoted. The value is HTML-escaped, so it can't break out of the tag. |:MDView-standalone| rewrites the file; |:MDView-start| edits the buffer. Setfalseto render such fields read-only.
open_preview_tab
When true, |:MDView-start| opens an nvim-tab preview (see
|:MDView-preview-tab|) instead of the browser. The relay/WASM pipeline still
runs normally in the background, so |:MDView-open| can still open the
browser afterwards if wanted.
browser.open_mode
How the preview browser is opened:
"default" — open the URL in your normal default browser as a new tab
(your extensions/theme/profile apply). Uses the OS opener.
mdview cannot programmatically close it, so
browser_autoclose and stop_on_browser_exit are no-ops in
this mode. This is the default.
"isolated" — spawn a dedicated browser process against a separate
mdview-only profile. Auto-close works (a distinct process),
but you don't get your extensions/bookmarks.
All other browser.* fields except theme, open_url, browser_autostart
and require_display apply to "isolated" mode only.
browser.theme
Preview theme name, passed to the client as?theme=. Ships:"github","dark-dimmed"(darker, lower-contrast),"plain"(neutral),"tokyonight"(dark), and"catppuccin"(Latte/Mocha). Each follows the OS prefers-color-scheme where it defines both; append-lightor-dark(e.g."catppuccin-dark") to pin the color scheme. Switch at runtime with |:MDView-theme|. Add a theme by dropping a CSS file in src/client/themes/ (import_base.cssfor the shared structure; optionally override--code-*for code colors) and a matching loader in main.ts.
browser.highlighter
Code-fence syntax highlighter, applied client-side after each render and
lazy-loaded so only the chosen one is fetched:
"hljs" — highlight.js (default). Light; token colors follow the theme's
--code-* variables.
"shiki" — exact TextMate/VSCode themes (tokyo-night, catppuccin, dark-plus,
…) mapped from browser.theme; heavier, grammars load on demand.
"nvim" — Neovim's own colors. Nothing is tokenized in the browser: the
highlighting color_my_ascii.nvim applied to the buffer is read
back out through its public API, resolved to #rrggbb, and pushed
per fenced block over the /spans channel. Preview and buffer then
agree by construction — same colorscheme, same groups — and a
:colorscheme change reaches the browser with the next push.
A block color_my_ascii did NOT paint goes to highlight.js rather
than losing its colors: its fence map covers 31 language tags,
highlight.js knows about 190, so this is an addition to the
JavaScript highlighter, not a replacement. color_my_ascii stays a
soft dependency — without it there is simply nothing to send.
The relay stores the last spans per room, so a reloaded tab is
repainted at once instead of waiting for the next edit.
"none" — no highlighting.
browser.external_links
Where links that leave the project open when clicked in the preview —http(s):, other URL schemes, and protocol-relative (//host/…) links: "new_tab" — open in a new browser tab (default), so the preview tab is not navigated away and stays live. "same_tab" — let the browser follow the link in place, replacing the preview. Use Back to return. In-project relative links (and#anchors) are not affected here — those are handled by|mdview-experimental.click_navigate|when it is on.
browser.cursor_marker
Show where the Neovim cursor is inside the preview:
"line" — a blinking bar in the left gutter at the cursor's line (default),
placed with the same sourcepos block + in-block interpolation as
the scroll sync, so it is line-accurate even inside multi-line
blocks. It marks the line, not the column.
"caret" — a caret at the exact cursor column. The WASM renderer wraps inline
text/code runs in <span data-sp="…"> carrying their source
position (byte columns, matching Neovim's byte-based cursor
column); the client maps the cursor there with a DOM Range. Falls
back to the line marker on blank lines and inside code blocks
(whose content is re-tokenized by the highlighter). Documents
render with the extra spans only in this mode.
"section" — spotlight the whole heading section the cursor is in and dim
the rest. The section runs from the governing heading (the last
heading at/before the cursor) to just before the next heading of
the same or higher rank, using the block data-sourcepos
boundaries. A dimmed/lifted block survives video compression far
better than a thin caret, so it's the clearest cue for a passive
screen-sharing viewer.
"off" — no marker.
All ride the scroll-sync ping, so they need |mdview-scroll_sync| on. Switch
at runtime with |:MDView-cursor|.
browser.selection_sync
Mirror the Neovim visual selection into the preview: what you select withv,Vor CTRL-V is highlighted in the browser, live, and disappears when you leave visual mode. Default:false— |:MDView-selection| toggles it. Meant for showing a document to other people — a lecture, a screen share, a walkthrough. Pointing at something in Neovim is invisible to an audience watching the browser tab; selecting it says "this part, here" in the window they are looking at. Off while you edit, for the same reason: an audience would otherwise watch you select things you are only operating on. Off costs nothing, so there is no third state to disable it in for good. All three visual modes keep their shape (charwise flows across lines, linewise covers whole lines, blockwise draws a column range on each line). The highlight is drawn as rectangles over the document, placed from the samedata-spsource-position spans the "caret" cursor marker uses, so turning this on also renders documents with those spans. Fenced code blocks carry no spans and are located by their own line structure instead. Switch it off with |:MDView-selection|, which also clears a highlight that is currently drawn.
browser.zoom
Preview font-size zoom factor (1.0= 100%, the default). |:MDView-start| sets#mdview-root's font-size to16 * zoompx; the stylesheet sizes everything in em, so the whole document scales proportionally. Adjust at runtime with |:MDView-zoom| (applies live). A non-default value is carried on the browser URL (?zoom=) so a reopened tab starts at the same zoom.
browser.preserve_blank_lines
Whether a run of two or more blank lines between blocks renders as that much vertical space (true) or collapses to a single paragraph gap (false, the default -- what CommonMark specifies). The spacing is added by the client as empty gap elements between the rendered blocks, not by rewriting the source, so|mdview-scroll_sync|and the cursor caret still map to the right lines. Blank lines inside a fenced code block are one block's content, not a gap between blocks, and are unaffected either way. Carried on the browser URL as?blanklines=1; toggle at runtime with |:MDView-blanklines|.
browser.focus
Whether the opened preview tab is allowed to take keyboard focus."browser"(default) opens it in front as usual;"nvim"keeps focus in the editor so you can keep typing."nvim"is clean on macOS (open -g), best-effort on Windows (captures the foreground window and restores it after opening — a window manager can still override it), and a no-op on Linux (there is no portable way to do it). Applies to thedefaultopen_mode only.
browser.behavior
What happens to the preview when you switch to a different markdown buffer
while a session is running:
"reuse" — the one open preview tab follows the active buffer (its
content is pushed into that tab's room). Default.
"new_tab" — each markdown buffer you switch to opens its own preview tab
(once per file; respects browser_autostart).
"manual" — switching buffers does nothing; open other files explicitly
with |:MDView-open|.
|:MDView-pin| suspends the follow for as long as you want to keep one
document on screen, without changing this setting.
browser.autodetect_browser
Try to locate an installed browser automatically (chrome, chromium, edge, firefox, in that order) whenbrowser/browser_cmdare unset. Isolated mode only.
browser.browser
Friendly name of a browser to prefer, e.g."chrome"or"firefox".
browser.browser_cmd
Absolute path to a browser executable; takes precedence over
browser / autodetection when set.
browser.browser_autoclose
Whether |:MDView-stop| also closes the browser tab it opened.
browser.browser_autostart
Whether |:MDView-start| opens a browser tab automatically.
browser.browser_args
Extra CLI arguments passed to the resolved browser executable.
start.push_strategy
"launcher"waits for the relay to become healthy, then performs one initial full push."try_push"retries the initial push with exponential backoff instead of a single wait.
install.repo / install.version
GitHubowner/repoand release tag the relay binary and client bundle are downloaded from. Overriderepoif you maintain a fork; overrideversionto pin an older release.
standalone.binary_path
Relay binary |:MDView-standalone| spawns.nil(default) uses whichever oneinstallresolved. Standalone mode needs a relay built with--watchsupport (v0.3.0+), so untilinstall.versionpoints at such a release, set this to a locally built one:
require("mdview").setup({
standalone = {
binary_path = "~/repos/mdview.nvim/native/server/mdview-server",
},
})
|:MDView-standalone| probes the binary first and reports a clear error if it's too old, rather than spawning a process that dies silently.
experimental.webtransport
Opt in to the WebTransport (HTTP/3) client transport. The client feature-detects it and falls back to WebSocket on any failure, so this is always safe to enable — but there is no HTTP/3 relay backend yet, so today it always falls back. Future tech; see docs/Roadmap/WebTransportAPI/DESIGN.md.
experimental.line_diff
Opt in to the line-diff transport: on each edit, send only the changed lines (as versioned \x03 envelopes) instead of the whole document, and have the client reassemble the full text. Saves bandwidth on large files; note that the client still re-renders the whole document (comrak needs full context), so on a loopback connection the benefit is modest. Correctness is preserved by versioning: a diff is applied only if its base matches the client's version, otherwise the client waits for the next full snapshot (sent on save and every 25 edits) and resyncs. The default full-text push remains the verified path.
experimental.click_navigate
Click-to-navigate (on by default). Clicking a relative link in the preview (e.g.[other](sub/other.md)) is intercepted by the client and sent to the relay's /nav bridge; Neovim polls it while a session is active, resolves the href against the source document's directory, and opens the target with:edit. Opening it makes it the active buffer, sobrowser.behaviorhandles the preview switch. External links (http:,mailto:), in-page anchors (#…) and absolute paths are left to the browser. Setfalseto let the browser follow links itself.
experimental.reverse_scroll
Opt in to reverse scroll (browser -> Neovim): scrolling the preview moves
Neovim's cursor to the matching position — the complement of the always-on
nvim -> browser scroll_sync. Implemented by polling the relay's
/scrollback bridge, so it follows with a small lag rather than instantly
(Neovim has no push channel back from the browser). Feedback loops are
suppressed on both sides. Off by default. When on, the preview shows a small
"⇅ scroll enabled" hint so a screen-sharing viewer knows they may scroll it.
5. COMMANDS
mdview.nvim registers one command, :MDView, built via lib.nvim's subcommand composer (lib.nvim.bindings.usercmd.composer): every subcommand below completes on <Tab>, including the typed arguments. *:MDView* :MDView start [file] [cwd=...] *:MDView-start* Ensures the mdview-server relay binary and client bundle are installed (downloading them from GitHub Releases on first use, checksum-verified), spawns the relay process, attaches the buffer-change autocommands (see |mdview-autocmds|), and opens the browser preview.[file]andcwd=...are both optional and may appear in either order:[file](tab-completed as a file path) targets that file instead of the current buffer;cwd=...overridesserver_cwdfor this spawn only (port=Ndoes the same forserver_port). Acwd=/port=token whose value does not parse (port=808O, a barecwd=) is rejected with a message rather than taken as the file to preview. Calling it again while already running is a no-op that still allows pushing a newly given[file](cwd=...is ignored once running). :MDView stop *:MDView-stop* Stops the relay process, detaches all mdview autocommands, shuts down the session, and — whenbrowser.browser_autocloseis true (the default) — closes the browser tab |:MDView-start| opened. :MDView toggle [file] [cwd=...] *:MDView-toggle* Starts the preview if no session is running, otherwise stops it. A thin dispatcher over |:MDView-start| / |:MDView-stop|; start-style args are forwarded when starting and ignored when stopping. :MDView standalone [file] [--no-browser] *:MDView-standalone* Starts the preview with NO Neovim in the chain at all: the relay binary watches the file on disk itself and pushes changes straight to the browser. Cheapest and most robust option — but it previews the file as SAVED, so unsaved buffer changes don't appear until you|:write|, and there is no scroll sync or cursor marker (nothing tracks a cursor). Runs onserver_port+ 100 so it can sit alongside a normal session. With--no-browserthe preview URL is reported in the notification, since a detached process's output goes nowhere. Requires a relay binary with--watchsupport (v0.3.0+); if the installed one is older, mdview says so instead of spawning a process that dies silently — seestandalone.binary_pathin |mdview-config|. See |mdview-standalone|. :MDView open *:MDView-open* Re-opens a browser tab for the current buffer against the *already-running* session. It does NOT start a new relay process — run |:MDView-start| first. Pushes the current buffer's content once so the new tab isn't empty, then opens the browser using the same key/token URL logic |:MDView-start| uses. If no session is running, or the current buffer has no file path, it fails loudly via|vim.notify()|instead of doing nothing. :MDView theme [name] *:MDView-theme* Switches the preview theme at runtime.[name]is one ofgithub,dark-dimmed,plain(optionally-light/-darksuffixed) and is tab-completed. Setsbrowser.themein the live config and, if a session is running, re-opens the preview so the change applies now (a fresh tab in "default"|browser.open_mode|, since the old tab can't be closed programmatically). With no argument it reports the current theme. :MDView weblogs *:MDView-weblogs* Opens a scratch buffer showing the relay server's captured stdout/stderr log, including[client]lines the browser client POSTs back for diagnostics (connection status, render/theme errors). Subject todebugin |mdview-config|. :MDView log [level] *:MDView-log* :MDView log export [path] *:MDView-log-export* Shows mdview's own internal structured log ring (launcher, live-push, ws_client, …) in a scratch buffer — distinct from |:MDView-weblogs|, which shows the *relay's* stdout.[level](trace/debug/info/warn/error, tab-completed) filters to that level and above;export [path]writes the ring to a file instead (defaultstdpath('log')/mdview-log.txt). :MDView file-log *:MDView-file-log* :MDView file-log on [path] *:MDView-file-log-on* :MDView file-log off *:MDView-file-log-off* :MDView file-log status *:MDView-file-log-status* :MDView file-log path [value] *:MDView-file-log-path* Toggles persistent file logging of the relay's stdout at runtime. It is opt-in and off by default, so a plain |:MDView-start| writes nothing to disk. When on, output is appended tofile_log_path— by defaultstdpath('log')/mdview/relay-<timestamp>.log, never alogs/directory in the current working directory. The bare form flips the state;statusreports without changing it. Seefile_login |mdview-config|. The destination can be set from the command line too:
:MDView file-log on ~/mdview.log enable and write there
:MDView file-log path ~/mdview.log set the path, leave on/off as-is
:MDView file-log path report the current path
:MDView file-log path default fall back to the configured default
~and relative paths are expanded to an absolute path when the command runs, so a later|:cd|doesn't move the log file. :MDView diagnose [path] *:MDView-diagnose* Writes a full component-state diagnostics report to a file (defaultstdpath('log')/mdview-diagnostics.txt, or[path]if given) and opens it. Covers environment, dependencies, install cache, effective config, the running session with a live /health probe, the browser URL that would be opened, and the recent internal log ring — everything needed to hand a bug report off for debugging. If[path]cannot be written (missing parent directory, read-only location) an error is reported and nothing is opened. :MDView preview-tab *:MDView-preview-tab* Toggles an nvim-tab Markdown preview for the current buffer: a read-only mirror buffer in its own tab, highlighted via Neovim's markdown Treesitter parser (falls back to Vim's bundledsyntax=markdownif the parser isn't installed). Live-synced on TextChanged/TextChangedI/BufWritePost. Deliberately decoupled from the browser/WASM rendering pipeline: no HTML is ever produced, no relay server or WebSocket is involved, and no external tool (e.g.glow) is shelled out to — this works standalone, with or without:MDView startever having run. See |mdview-architecture| andopen_preview_tabin |mdview-config| to make:MDView startopen this instead of the browser.
Live preview controls
These change the open preview tab without a reload: each sets the matchingbrowser.*option (so a re-opened tab keeps it) and, while a session runs, pushes a control update over the socket. :MDView cursor [mode] *:MDView-cursor* Sets the Neovim-cursor marker in the preview (browser.cursor_marker):line(blinking bar at the cursor line),caret(caret at the exact cursor column),section(spotlight the current heading section, dim the rest), oroff. No argument reports the current mode. Tab-completed. :MDView selection [action] *:MDView-selection* Switches the visual-selection mirror on/off (on|`off`|toggle; no argument toggles) — seebrowser.selection_syncabove. Off by default; this is the switch you flip when you start showing the document to someone. Switching it on prepares the tab at once (it re-renders with the source- position spans the mirror needs, so the first thing you point at appears without a hitch) and draws a selection that is already active. Switching it off clears a highlight that is drawn right now, instead of leaving it stranded in the tab. :MDView sync [action] *:MDView-sync* Pauses/resumes the nvim->browser scroll sync (pause|`resume`|toggle). While paused, moving the cursor no longer scrolls the preview or moves its cursor marker — useful to jump to a reference spot without dragging a screen-sharing viewer along. No argument reports the state. :MDView pin [action] *:MDView-pin* Holds the preview on the document it is showing instead of letting it follow the active buffer (on|`off`|toggle|status; no argument toggles). Under the defaultbrowser.behavior= "reuse" there is one preview tab and it follows you: switch Markdown buffers and the browser switches too. That is right while writing and wrong while reading -- opening a second file to check something takes the document you were showing off the screen, and only switching back brings it there again. While pinned, everything another buffer would send into the pinned tab's room is dropped at the source: the content push, the scroll ping, the visual-selection mirror, and (under "new_tab") auto-opened tabs. The pinned document's own edits, scrolling and selections still reach the preview normally -- a pin filters which buffer may drive the tab, it does not pause the session.offalso catches the tab up with the buffer you are actually in, rather than leaving it on the released document until the next buffer switch. A pin is session state, not config: |:MDView-stop| and a fresh |:MDView-start| clear it, since it held that tab on that document. |:MDView-open| moves a live pin instead of being blocked by it -- it re-points the tab at the current buffer on purpose. Tab-completed. :MDView zoom [step] *:MDView-zoom* Adjusts the preview font-size zoom (browser.zoom).+/-step by 10% (clamped 50%-300%),resetreturns to 100%, and a bare number sets a factor (1.5) or a percentage (150). No argument reports the current zoom. Handy when a video call downsamples the shared screen. :MDView reveal [action] *:MDView-reveal* Reveals/hides every *private block* at once (on|`off`|toggle). A fenced block with the info stringprivaterenders its contents as normal Markdown but is blurred by default, so third-party names/numbers stay hidden during a screen share. Reveal one block by clicking it, or all with this command. Live-only — a freshly opened tab always starts hidden. :MDView blanklines [action] *:MDView-blanklines* Switches whether the preview shows every blank line between blocks as extra vertical space, or collapses runs of them to a single paragraph gap -- CommonMark's own behavior, and the default.on|`off`|toggle, tab completed; no argument toggles. Setsbrowser.preserve_blank_lines(so a reopened tab keeps it) and, while a session runs, pushes a live/controlupdate so the open tab re-renders without a reload. Seebrowser.preserve_blank_linesin |mdview-config|. :MDView overlay [name] [action] *:MDView-overlay* :MDView overlay list *:MDView-overlay-list* Toggles a preview overlay (browser.overlays): an independent, toggleable layer drawn over the document. Currentlytoc— a floating outline that highlights the section the cursor is in and shows how far along you are. Without a name (or withlist) it reports the known overlays and their state. Both arguments are tab-completed. :MDView breadcrumbs *:MDView-breadcrumbs* :MDView breadcrumbs export [path] *:MDView-breadcrumbs-export* :MDView breadcrumbs clear *:MDView-breadcrumbs-clear* Shows/exports/clears the session breadcrumbs: a rough Markdown outline of which document + heading section the cursor was in and when, recorded while the session runs (deduped so a new line appears only on an actual section or document change).exportwrites a.mdfile (defaultstdpath('log')/mdview-breadcrumbs.md). Useful for writing notes and follow-ups after a call. Recording is gated bybreadcrumbs(|mdview-config|, default true) and reset each session.
Lua API
*mdview.open()*require("mdview").open({opts})is the function behind |:MDView-open|. {opts} (all optional):browser_url(explicit URL override),browser_cmd,browser_args. Returnstrueon success,falseon failure (after notifying why).
6. AUTOCOMMANDS
All registered in a single augroup (MdviewAutocmds) bymdview.bindings.autocmds.attach()when |:MDView-start| runs, and torn down together by |:MDView-stop|. The BufEnter handlers (snapshot, buffer switch, breadcrumbs) share one autocmd,bindings/autocmds/enter_hub.lua.
Event Purpose
BufEnter Take a session snapshot of the entered
buffer.
TextChanged, TextChangedI Push the full current buffer content to
the relay for live preview.
BufWritePost Same full push, triggered on save.
CursorMoved, CursorMovedI Send cursor line + total lines to the
relay (throttled) so the browser preview
scrolls to follow. nvim-to-browser only;
gated behind scroll_sync (default true).
VimLeavePre Stop the relay process so it doesn't
outlive the Neovim session. NOT
pattern-restricted to markdown files —
this must fire regardless of which buffer
is focused when Neovim quits, or the
relay process is orphaned.
Two additional autocmd modules exist in the source tree but are intentionally
not wired up (on_text_change.lua, bufwrite.lua) — kept only for
reference; live_push.lua supersedes both.
7. KEYMAPS
mdview.nvim does not define any keymaps of its own — only the user commands in |mdview-commands|. Map them yourself if you want keymaps:
vim.keymap.set("n", "<leader>mp", "<cmd>MDView start<cr>",
{ desc = "mdview: start preview" })
vim.keymap.set("n", "<leader>mq", "<cmd>MDView stop<cr>",
{ desc = "mdview: stop preview" })
Since these are plain|vim.keymap.set()|calls with adesc, they show up correctly in which-key.nvim (https://github.com/folke/which-key.nvim) without any extra integration needed.
8. HEALTH CHECK
*:checkhealth-mdview*
:checkhealth mdview
Reports, without downloading or installing anything: - Neovim >= 0.9 - lib.nvim present (a required dependency — reported as an error if missing) -curlfound on PATH -tarfound on PATH - Whether the mdview-server binary for the configuredinstall.versionis already cached, and where - Whether the client bundle for the configuredinstall.versionis already cached, and where A missing binary/client bundle is reported as a warning, not an error — both are downloaded automatically the first time |:MDView-start| runs.
9. ARCHITECTURE
plugin/mdview.lua Load guard only (require('mdview').setup()
does the actual work)
lua/mdview/
init.lua Public API: setup(), open()
health.lua checkhealth provider
config/
init.lua Deep-merge setup() over DEFAULTS
DEFAULTS.lua Single typed source of truth for defaults
browser.lua Browser resolution config + validation
usrcmd_start.lua :MDView start strategy config
core/
state.lua Runtime handles (server proc, browser,
session token)
session.lua Per-buffer content snapshots
events.lua Internal event helpers
adapter/
install.lua Downloads + checksum-verifies the relay
binary and client bundle from GitHub
Releases
server_args.lua Resolves the relay binary path + spawn args
(port, token, web-root)
runner.lua Cross-platform process spawn/stop
ws_client.lua Pushes buffer text to the relay over HTTP,
with retry/backoff
log.lua In-memory + optional file logging
browser/ Cross-platform browser executable
resolution and launching
preview_tab.lua Standalone nvim-tab preview: no HTML, no
relay/WebSocket, no external tool — a
Treesitter-highlighted mirror buffer (see
|:MDView-preview-tab|)
bindings/
usrcmds/ :MDView <subcommand>, built via
lib.nvim.bindings.usercmd.composer — one action
module per subcommand (start, stop, open,
toggle, theme, log, file_log, diagnose, …)
autocmds/ BufEnter, TextChanged*, BufWritePost,
CursorMoved*, VimLeavePre wiring (see
|mdview-autocmds|); preview_tab_sync.lua
is separate, self-managing, and
independent of :MDView start/stop
keymaps/ Reserved; mdview.nvim ships no keymaps (see
|mdview-keymaps|)
helper/ Small cross-platform utilities; several
delegate to lib.nvim
native/server/ Go relay server: binds 127.0.0.1 only,
relays raw text over WebSocket per document
key, serves the static client bundle
native/wasm-render/ Rust crate compiled to WebAssembly:
Markdown -> HTML (comrak) piped through an
allowlist sanitizer (ammonia)
src/client/ Thin TypeScript glue: WebSocket transport
+ loads the WASM module + assigns its
(already sanitized) output to the DOM
docs/BINDINGS.md Cheatsheet of every command/autocmd
docs/Roadmap/Roadmap.md Planned features and fixed-bug log
Module load order: config -> core -> adapter -> bindings -> init
10. SECURITY MODEL
mdview.nvim is designed so that no server-side process ever renders
Markdown, and no unsanitized HTML ever reaches the DOM:
- Rendering AND sanitization happen together, client-side, inside the
WASM module (comrak -> ammonia). There is no code path that returns
rendered HTML without it having passed through the sanitizer first.
- The Go relay only ever transports raw text. It has no rendering logic
and therefore no HTML-related attack surface at all.
- The relay binds to 127.0.0.1 only — never reachable from the network.
- Every WebSocket upgrade is checked against the request's Origin header,
rejecting cross-site/DNS-rebinding attempts against the local server.
- Every request (WebSocket and HTTP) must present a per-session token,
generated fresh each time |:MDView-start| runs and known only to the
Neovim process and the browser tab it opens. In standalone mode
(|:MDView-standalone|) the token is generated the same way, by whichever
side started the relay; nothing about the check itself changes.
- The downloaded relay binary and client bundle are checksum-verified
against the release's published checksums.txt before first execution.
11. BACKGROUND & STANDALONE
By default a preview lives and dies with your Neovim instance. |:MDView-standalone| gives you one that stays, with no Neovim in the chain: Command Survives :qa Unsaved buffer Scroll sync / cursor ------------------ ------------ -------------- -------------------- |:MDView-start| no yes yes |:MDView-standalone| yes no (on disk) no Rule of thumb: |:MDView-start| while you're editing the document, |:MDView-standalone| when you just want it rendered and kept open.
Typical uses
- A reference doc (spec, API notes, cheat sheet) beside your work, running
for days regardless of what you do to your editor.
- Previewing a file that some other tool, or another person, generates.
- The cheapest always-on preview: one small process, no Neovim, immune to
whatever happens in your editor.
From the terminal
nvim +MDView --background file.mdis NOT valid Neovim syntax —+cmdtakes no trailing flags. The supported spelling is the wrapper script:
scripts/mdview-bg.sh README.md # preview in the browser
scripts/mdview-bg.sh --no-browser notes.md # relay only, prints the URL
On Windows,scripts\mdview-bg.ps1takes-NoBrowser. Each runs a throwaway headless Neovim just long enough to fire |:MDView-standalone|, then quits; the relay keeps running on its own. Symlink one onto your PATH for a general-purpose "render this Markdown" command. Environment:MDVIEW_PATH(mdview.nvim checkout; derived from the script by default),LIB_NVIM_PATH(if lib.nvim isn't next to it),MDVIEW_STANDALONE_BIN(relay override, same role asstandalone.binary_path),NVIM(binary).
Without a browser at all
|:MDView-preview-tab| renders the buffer as a read-only, Treesitter- highlighted mirror in a Neovim tab — no relay, no WebSocket, no browser. Not a full preview (no CSS, themes or rendered tables), but it needs nothing but Neovim, which makes it the answer on a machine with no GUI. Full guide with examples: docs/standalone.md in the repository.