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 *mdview-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-intro*

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 *mdview-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)
  - curl on PATH — downloads the relay binary and client bundle
  - tar on 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 *mdview-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-config*

                                                            *mdview.setup()*
Call require("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. only browser.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. Widens ft_pattern to
  { "*" } (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. Was experimental.any_file before 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

  When true (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. Set false to render checkboxes read-only and, in start mode,
  stop that poll.

sync_fields

  When true (default), editing a raw-HTML text field written in the source
  with a name (<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 its name attribute rather
  than by line — name must 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. Set false to 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 -light or -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.css for 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 with
  v, V or 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 same
  data-sp source-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 to 16 * zoom px; 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 the default open_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) when browser / browser_cmd are 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

  GitHub owner/repo and release tag the relay binary and client bundle are
  downloaded from. Override repo if you maintain a fork; override
  version to pin an older release.

standalone.binary_path

  Relay binary |:MDView-standalone| spawns. nil (default) uses whichever one
  install resolved. Standalone mode needs a relay built with --watch
  support (v0.3.0+), so until install.version points 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, so browser.behavior
  handles the preview switch. External links (http:, mailto:), in-page
  anchors (#…) and absolute paths are left to the browser. Set false to 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-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] and
  cwd=... 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=... overrides server_cwd for this spawn only
  (port=N does the same for server_port). A cwd=/port= token whose
  value does not parse (port=808O, a bare cwd=) 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 — when browser.browser_autoclose is 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 on
  server_port + 100 so it can sit alongside a normal session. With
  --no-browser the preview URL is reported in the notification, since a
  detached process's output goes nowhere.
  Requires a relay binary with --watch support (v0.3.0+); if the installed
  one is older, mdview says so instead of spawning a process that dies
  silently — see standalone.binary_path in |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 of github,
  dark-dimmed, plain (optionally -light/-dark suffixed) and is
  tab-completed. Sets browser.theme in 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 to debug
  in |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 (default stdpath('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 to file_log_path — by default
  stdpath('log')/mdview/relay-<timestamp>.log, never a logs/ directory
  in the current working directory. The bare form flips the state; status
  reports without changing it. See file_log in |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 (default
  stdpath('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 bundled syntax=markdown if 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 start ever having run. See |mdview-architecture|
  and open_preview_tab in |mdview-config| to make :MDView start open this
  instead of the browser.

Live preview controls

These change the open preview tab without a reload: each sets the matching
browser.* 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), or
  off. 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) — see browser.selection_sync above. 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 default browser.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.

  off also 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%), reset returns 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 string private renders 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. Sets browser.preserve_blank_lines (so a
  reopened tab keeps it) and, while a session runs, pushes a live /control
  update so the open tab re-renders without a reload. See
  browser.preserve_blank_lines in |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. Currently toc — a floating outline that
  highlights the section the cursor is in and shows how far along you are.
  Without a name (or with list) 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). export writes a .md file (default
  stdpath('log')/mdview-breadcrumbs.md). Useful for writing notes and
  follow-ups after a call. Recording is gated by breadcrumbs
  (|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. Returns true on success, false on failure (after
notifying why).

6. AUTOCOMMANDS *mdview-autocmds*

All registered in a single augroup (MdviewAutocmds) by
mdview.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-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 a desc, they show up
correctly in which-key.nvim (https://github.com/folke/which-key.nvim)
without any extra integration needed.

8. HEALTH CHECK *mdview-health*

                                                          *: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)
  - curl found on PATH
  - tar found on PATH
  - Whether the mdview-server binary for the configured
    install.version is already cached, and where
  - Whether the client bundle for the configured install.version is
    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 *mdview-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-security*

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 *mdview-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.md is NOT valid Neovim syntax — +cmd takes
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.ps1 takes -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 as standalone.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.