日本語版はこちら / Japanese version — Full Japanese/CJK support with kinsoku shori and BudouX phrase segmentation.
A Markdown rendering engine for Neovim. Transforms raw Markdown into richly highlighted, interactive content — right inside your editor. Supports floating windows, tab views, and a pager mode for less-like usage from the command line.
- Rich inline formatting — bold, strikethrough, inline code, links, Obsidian
==highlight==, all rendered in-place - Tables — box-drawing borders, column alignment, proportional sizing, and inline formatting within cells
- Callouts & folds — GitHub and Obsidian alert types with colored borders, icons, and folding you can toggle by clicking or with
za/<CR> - Code blocks — fenced blocks with treesitter syntax highlighting; expandable when truncated (click or
za/<CR>) - Images — local and web images (PNG, JPEG, WebP, GIF, animated GIF) displayed inline via terminal graphics protocol
- Video — local and web video (MP4, WebM, MOV, AVI, MKV, M4V) played as animated frames inline
- Mermaid diagrams — rendered as images inline
- PlantUML diagrams — rendered as images inline by a local renderer, or by a server you name
- CommonMark paragraphs — soft-wrapped source lines join into one paragraph, including a list item's continuation lines and a blockquote body; no extra space is inserted between CJK characters
- Nested block structure — a blockquote, callout, or fenced code block indented to a list item's content is rendered in place, not as literal text
- CJK-aware word wrapping — JIS X 4051 kinsoku shori + optional BudouX phrase segmentation via budoux.lua
- Clickable links — mouse click to open URLs; hover the mouse over a link to peek the full URL in a subtle floating window; OSC 8 hyperlink support for compatible terminals
<details>support — collapsible sections you can toggle by clicking or withza/<CR>, respecting theopenattribute- Status footer — the floating preview shows the file name, your position in the source, and a box-drawing progress bar on its bottom border, without stealing a content row or touching your statusline
- Library API — use the rendering engine programmatically from your own plugins
The repo bundles a showcase Markdown file demonstrating every feature. After cloning, view it with the pager:
git clone https://github.com/delphinus/md-render.nvim
cd md-render.nvim
nvim +"MdRender pager" assets/showcase.mdOr, once the plugin is installed, run :MdRender demo to see a built-in demo of every supported notation.
- Neovim >= 0.12 (uses
vim.api.nvim_ui_sendfor terminal writes) - For inline images and video: a terminal supporting the Kitty graphics protocol. Verified on WezTerm, Kitty, and Ghostty (macOS/Linux).
Optional dependencies
| Dependency | Purpose | Fallback |
|---|---|---|
| curl | Download web images and video | Custom function via set_download_fn() |
FFmpeg (ffmpeg / ffprobe) |
JPEG/WebP → PNG conversion, animated GIF / video frame extraction | Falls back to ImageMagick (images only; video requires ffmpeg) |
ImageMagick (magick) |
JPEG/WebP → PNG, animated GIF frame extraction | sips (macOS) handles static conversion; animated GIF requires ffmpeg or magick |
Mermaid CLI (mmdc) |
Render Mermaid diagrams as images | Falls back to npx -y @mermaid-js/mermaid-cli |
PlantUML (plantuml, or java with $PLANTUML_JAR) |
Render PlantUML diagrams as images | A PlantUML server, if you name one (needs curl); otherwise the fence stays a code block |
| budoux.lua | CJK phrase-level line breaking (BudouX) | Character-level splitting (kinsoku rules still apply) |
| Treesitter parsers | Syntax highlighting in code blocks | Code blocks rendered without highlighting |
| nvim-web-devicons or mini.icons | File type icons in code block headers | Built-in icon table |
For image/video format conversion and animation support, the plugin tries tools in this order:
| Use case | 1st | 2nd | 3rd |
|---|---|---|---|
| Static image conversion (JPEG/WebP → PNG) | sips (macOS) |
ffmpeg |
magick |
| Animated GIF frame extraction | ffmpeg |
magick |
— |
| Video frame extraction | ffmpeg |
— | — |
{
"delphinus/md-render.nvim",
version = "*",
dependencies = {
{ "nvim-tree/nvim-web-devicons", version = "*" }, -- optional: file type icons in code blocks
{ "delphinus/budoux.lua", version = "*" }, -- optional: CJK phrase-level line breaking
},
keys = {
{ "<leader>mp", "<Plug>(md-render-preview)", desc = "Markdown preview (toggle)" },
{ "<leader>mt", "<Plug>(md-render-preview-tab)", desc = "Markdown preview in tab (toggle)" },
{ "<leader>md", "<Plug>(md-render-demo)", desc = "Markdown render demo" },
},
}vim.pack.add({
"https://github.com/delphinus/md-render.nvim",
-- optional:
"https://github.com/nvim-tree/nvim-web-devicons",
"https://github.com/delphinus/budoux.lua",
})local add = MiniDeps.add
add({
source = "delphinus/md-render.nvim",
depends = {
"nvim-tree/nvim-web-devicons", -- optional
"delphinus/budoux.lua", -- optional
},
})Why not other Markdown previewers?
- markdown-preview.nvim — Excellent for true browser-quality rendering, but requires a browser context. md-render runs entirely inside the terminal.
- render-markdown.nvim — Beautiful in-buffer rendering, but modifies the editing buffer itself. md-render keeps your editing buffer untouched and renders into a separate floating/tab window or pager view.
- mcat — Closest in spirit (a pure-terminal Markdown renderer), but lacks complex layout features like auto-folding tables, click-to-toggle folds, and CJK word wrapping.
md-render.nvim aims to be a dedicated previewer that runs entirely in the terminal, with rich layout support and first-class CJK handling.
The plugin provides <Plug> mappings but does not set any default keybindings. Map them yourself:
vim.keymap.set("n", "<leader>mp", "<Plug>(md-render-preview)", { desc = "Markdown preview (toggle)" })
vim.keymap.set("n", "<leader>mt", "<Plug>(md-render-preview-tab)", { desc = "Markdown preview in tab (toggle)" })
vim.keymap.set("n", "<leader>md", "<Plug>(md-render-demo)", { desc = "Markdown render demo" })<Plug> mapping |
Description |
|---|---|
<Plug>(md-render-preview) |
Toggle a floating preview window for the current Markdown buffer |
<Plug>(md-render-preview-tab) |
Toggle a tab preview for the current Markdown buffer |
<Plug>(md-render-toggle) |
Toggle the current window between source and render mode in place |
<Plug>(md-render-auto) |
[experimental] Toggle auto mode (render outside Insert) for the current buffer |
<Plug>(md-render-split) |
Open a split showing source and rendered Markdown |
<Plug>(md-render-demo) |
Show a demo window with all supported Markdown notations |
Inside a rendered preview (floating, tab, or in-place toggle), these buffer-local keys are set automatically:
| Key | Action |
|---|---|
za |
Toggle the fold / expandable region under the cursor (no-op elsewhere) |
<CR> |
Toggle the fold / expandable region under the cursor (no-op elsewhere) |
<LeftMouse> |
Toggle folds, expand regions, and open links by clicking |
q / <Esc> / <C-c> |
Close the window (floating / tab mode only) |
The plugin exposes a single :MdRender command with subcommands:
| Command | Description |
|---|---|
:MdRender |
Floating preview window (alias of :MdRender float) |
:MdRender float |
Toggle a floating preview window |
:MdRender tab |
Toggle a tab preview |
:MdRender toggle |
Toggle the current window between source and render mode in place |
:MdRender split |
Open a split showing source and rendered Markdown (honours :vert, :tab, :topleft, :botright) |
:MdRender auto [on|off|toggle] |
[experimental] Auto-toggle source/render based on Insert mode (per buffer) |
:MdRender textsize [on|off|toggle] |
[experimental] Scale headings via the Kitty text sizing protocol (on by default) |
:MdRender pager |
Pager mode — full-screen, no chrome, q to quit Neovim |
:MdRender demo |
Show a demo window with all supported Markdown notations |
Tab completion lists the subcommands for the first arg, and on / off / toggle after auto and textsize.
Backwards compatibility. The legacy top-level commands (
:MdRenderTab,:MdRenderToggle,:MdRenderSplit,:MdRenderAuto,:MdRenderPager,:MdRenderDemo) still work and forward to the new dispatcher. They print a one-shot deprecation warning per Neovim session and will be removed in a future major version.
:MdRender toggle swaps the current window between the source Markdown buffer and a rendered view of it — without opening a new tab or floating window. This is designed for split layouts where you want, for example, code in one split and the rendered README in the other.
:vsplit README.md
:MdRender toggleBehavior:
- The render buffer is read-only and reused across toggles (one render buffer per source).
- When the same source is shown in multiple windows, only the invoking window swaps; edits from other windows are reflected on the next toggle into render mode.
- Cursor position round-trips between source and render via the source-line mapping.
number,relativenumber, andlistare turned off on render-mode windows. The originals are stashed on the window and restored when toggling back to source.- Inside render mode,
q/<Esc>/<C-c>are not bound to close — call:MdRender toggleagain to return to source mode.<LeftMouse>,za, and<CR>still toggle folds and expand regions (and<LeftMouse>opens links).
Experimental. This feature is new and the UX may change. Please report issues or rough edges.
:MdRender auto on keeps the current buffer in render mode while in Normal mode and swaps back to source automatically when you start editing. Pass off to disable, or call :MdRender auto (or :MdRender auto toggle) to toggle. To opt every Markdown buffer in:
autocmd FileType markdown silent! MdRender auto onSee :help :MdRender-auto for behavior details — the i / I / a / A / o / O remaps, :w forwarding, and the editing operations that are blocked on the read-only render buffer.
Experimental. New and Kitty-only. The UX may change or the feature may be withdrawn. Please report issues or rough edges.
Kitty 0.40 added the text sizing protocol (OSC 66), which draws text at a multiple of the base font size. md-render uses it to give every heading level its own size:
| Level | # |
## |
### |
#### |
##### |
###### |
|---|---|---|---|---|---|---|
| Size | 2.00x | 1.75x | 1.50x | 1.40x | 1.25x | 1.17x |
This is on whenever the terminal supports it — no configuration needed. Turn it off with :MdRender textsize off, or permanently with:
require("md-render.text_size").setup { enabled = false }The ladder is fixed. Kitty's s= scale multiplies the cells a run occupies, not just the font, so every level stays at s=2 — one extra rendered row, never more — and the sizes below 2x come from the protocol's fractional scale plus a w= width per run. The protocol caps d at 15 and w at 7, which is why the deepest level lands on 1.17x rather than something closer to plain.
Headings wrap at 1 / size of the usual width so that every level scales rather than only the ones that happen to fit, and each wrapped line gets its own two-row block.
A fractionally scaled heading goes out as several runs, because each has to declare its width in whole cells while its text does not measure a whole number of them. That width is rounded up — Kitty drops characters that do not fit — and the runs are cut where the leftover cell disappears: at a boundary that comes out exact where there is one, and otherwise after a space, so the slack reads as a slightly wider word gap instead of a hole in a word.
The scaled text is written straight to the terminal, the same way inline images are, so Neovim knows nothing about it. The plain-size heading stays in the buffer underneath and the scaled text is painted over it — every terminal repaint degrades to the normal heading rather than to a blank line, and y / / / :w still see the real text.
Known limitations:
-
Kitty >= 0.40 only. Support is detected by asking the terminal to identify itself (XTVERSION) and requires a positive answer. This is deliberately strict: some terminals swallow an OSC 66 sequence together with its payload text, which would delete the heading rather than fall back to unscaled text. Everywhere else the feature costs nothing and headings render as they always did.
-
Inline formatting inside a heading (inline code, links,
==highlight==) loses its colors while scaled. -
Every level reserves a two-row block while only
#fills it, so the rest are centered in theirs (v=2) instead of sitting against the top edge, which is the protocol's default. At######— 1.17x in a block twice as tall — the top edge left almost a whole row empty under the heading. -
The level icon stays at normal size, but goes out as a run of its own rather than as the plain text underneath, so it is centered in the same block and stays level with the heading it labels (
n=1:d=2againsts=2cancels the cell scale exactly). Its own run is also what keeps it legible: Kitty gives a scaled run exactlyscells per source cell, and these Nerd Font glyphs report as one cell wide while being drawn wider, so sharing the heading's run would clip the icon —would render as a bare "H". Alone,w=1gives it the two-cell block the icon already occupies, and it fits. -
A heading whose second row would fall outside the window stays plain until scrolled into view.
-
A repaint by anything else (another plugin forcing a redraw, a message or popup overlapping the window, the terminal shifting cells for a mouse scroll) drops the heading back to plain size, and there is no way to stop that happening. Recovery is on three signals, in order of how quickly they arrive: a scroll, resize, or window opening or closing repaints at once; any redraw at all is picked up from a decoration provider on the tick after it; and
SafeStatecovers the rest, rate-limited, with a 500 ms timer behind it for a repaint that never settles. md-render's own repaints are handled properly rather than waited out — inline images and scaled headings both draw outside Neovim's grid and are both destroyed by a full repaint, so whichever of the two repaints announces it and the other puts itself back at once. -
A plugin that repaints inside
eventignore = "all"costs a frame that cannot be recovered any sooner. Autocmds are how everything above learns that anything happened, and that setting silences all of them. The decoration provider still fires — it is not an autocmd — so the heading comes back on the next tick, but it does go for that one frame. nvim-scrollview is the known case: it opens a float the size of the whole editor, moves a dozen small ones and closes them again, about twenty times a second while the mouse moves, all of it insideeventignore = "all". Measured over fifteen seconds, 308 calls tonvim_open_winproduced oneWinNew. To turn it off while a preview is on screen, match onb:md_render(set on every buffer md-render renders into) rather than pairing open and close events — a preview can be opened more than once and split by hand, and asking "is one open" needs no bookkeeping:local off = false vim.api.nvim_create_autocmd({ "WinNew", "WinClosed", "BufWinEnter" }, { callback = vim.schedule_wrap(function() local want = false for _, w in ipairs(vim.api.nvim_list_wins()) do if vim.b[vim.api.nvim_win_get_buf(w)].md_render then want = true break end end if want ~= off then off = want vim.cmd(want and "ScrollViewDisable" or "ScrollViewEnable") end end), })
-
Each layout change costs a full-screen repaint to clear the previous scaled run, so scrolling is more expensive than usual. When the window also holds images the image redraw does that clearing, and the scaled text is simply written after it.
-
The Telescope and Snacks previewers opt out. They redraw on every cursor step, and a full-screen repaint per step is not something a picker can afford.
See :help md-render-text-size for the full rationale.
:MdRender split opens a split showing the source buffer and the rendered view together. Direction follows standard Vim split modifiers:
:MdRender split— horizontal split:vert MdRender split— vertical split (typical "README + code" layout):tab MdRender split— split inside a new tab:topleft MdRender split— place at the top:botright MdRender split— place at the bottom
Edits to the source propagate live, and cursor/scroll position is synchronized in both directions. See :help :MdRender-split for full behavior and the inline-image limitation.
less
Use :MdRender pager to view Markdown files like less:
nvim +"MdRender pager" README.mdAdd a shell alias for convenience:
alias mdless='nvim +"MdRender pager"'
mdless README.mdrequire("md-render.telescope").previewer() creates a previewer that can be
passed to any telescope.nvim
picker — builtin, extension, or custom:
local previewer = require("md-render.telescope").previewer()
require("telescope.builtin").find_files({ previewer = previewer })
require("telescope").extensions.egrepify.egrepify({ previewer = previewer })The previewer automatically handles three kinds of files:
| File type | Behavior |
|---|---|
Markdown (.md, .markdown) |
Full md-render rendering with highlights, links, and images |
| Image / Video (PNG, JPEG, WebP, GIF, MP4, ...) | Inline display via Kitty graphics protocol |
| Other files | Falls back to telescope's default previewer with syntax highlighting |
For grep-based pickers, the preview scrolls to the matched line.
A shortcut for builtin pickers. Wraps telescope.builtin pickers with the
md-render previewer. All arguments are passed through:
:Telescope md_render find_files
:Telescope md_render live_grep cwd=~/notes
:Telescope md_render grep_string search=TODOrequire("md-render.snacks").preview() creates a preview function for
snacks.nvim pickers. It handles the
same three file types as the telescope previewer (Markdown, image/video, and
fallback).
Configure it globally to apply to all pickers:
require("snacks").setup({
picker = {
preview = require("md-render.snacks").preview(),
},
})Or per-source:
require("snacks").setup({
picker = {
sources = {
files = { preview = require("md-render.snacks").preview() },
grep = { preview = require("md-render.snacks").preview() },
},
},
})Images don't show — only their alt text or filenames appear
Inline image display requires a terminal that supports the Kitty graphics protocol. Verify you're using WezTerm, Kitty, or Ghostty. tmux and other multiplexers may strip the image escape sequences unless explicitly configured to pass them through.
Videos appear as a single static frame
Video frame extraction requires ffmpeg to be installed and available in $PATH. Without it, the plugin falls back to displaying just the first frame as a still image. Install it via your package manager (e.g. brew install ffmpeg).
Mermaid diagrams don't render
Mermaid rendering requires the mmdc binary from @mermaid-js/mermaid-cli. If mmdc isn't installed globally, the plugin falls back to npx -y @mermaid-js/mermaid-cli, which is significantly slower on first invocation. Install it globally with npm install -g @mermaid-js/mermaid-cli for faster rendering.
PlantUML diagrams don't render
Fenced blocks tagged plantuml or puml are rendered locally when a plantuml binary is on your PATH (most package managers ship one), or when java is available and $PLANTUML_JAR points at a readable plantuml.jar. Install one of those and the fence becomes a diagram.
There is no fallback unless you ask for one. PlantUML renders on a server by design, and rendering on somebody else's means sending the diagram there, so the plugin will not choose that for you — without a local renderer, a plantuml fence stays a code block. Name a server and it will be used:
require("md-render.image").setup {
-- Your own instance, or "https://www.plantuml.com/plantuml" for the public
-- one. Either way the diagram source is sent there, so pick knowing that.
plantuml_server = "https://plantuml.example.com/plantuml",
}The server also needs curl. Rendered diagrams are cached under stdpath("cache")/md-render/plantuml, keyed by the diagram source, so a diagram is only sent once.
Japanese text wrapping looks unnatural
By default, md-render applies JIS X 4051 kinsoku shori (forbidden line-break rules) at the character level. For phrase-level segmentation that respects natural word boundaries in Japanese, install budoux.lua — the plugin will automatically detect and use it.
Code blocks have no syntax highlighting
Syntax highlighting requires the corresponding treesitter parser to be installed. For example, to highlight Lua code blocks, install the lua parser via :TSInstall lua (with nvim-treesitter) or via Neovim 0.11+'s built-in parser management.
Programmatic API
Use the rendering engine to build highlighted content programmatically:
local md = require("md-render")
-- Render a single line of markdown
local text, highlights, links = md.Markdown.render("**bold** and [link](https://example.com)")
-- Build full document content
local ContentBuilder = md.ContentBuilder
local b = ContentBuilder.new()
b:render_document(lines, {
max_width = 80,
indent = " ",
repo_base_url = "https://github.com/user/repo",
autolinks = {
{ key_prefix = "JIRA-", url_template = "https://jira.example.com/browse/JIRA-<num>" },
},
})
local content = b:result()
-- Apply to a buffer
local buf = vim.api.nvim_create_buf(false, true)
local ns = vim.api.nvim_create_namespace("my_ns")
md.display_utils.apply_content_to_buffer(buf, ns, content)
-- Display images (requires a Kitty Graphics Protocol compatible terminal)
-- Images are automatically cleaned up when the window is closed.
local win = vim.api.nvim_get_current_win()
md.display_utils.setup_images(win, content, ns)make testThis runs all tests/*_test.lua files via nvim --headless. New test files matching the *_test.lua pattern are picked up automatically.
MIT — see LICENSE.
