README.md
August 5, 2026 · View on GitHub
A comprehensive Neovim plugin that provides modern markdown editing capabilities, implementing features found in popular editors like Typora, Mark Text, and Obsidian.
Note
v2.0 includes breaking changes. Please read the migration guide before upgrading: v2.0 Migration Guide (Wiki).
Key Features: Zero dependencies • Works with any filetype • Full test coverage (85%+) • Extensively documented
Examples features
https://github.com/user-attachments/assets/493361af-f191-4faf-ac1c-4da01222e37d
https://github.com/user-attachments/assets/5ddbc02c-68ba-44f0-8cc0-41807a23e788
Similar plugins
- obsidian.nvim - A Neovim plugin for writing and navigating Obsidian vaults with features like autocompletion for notes/tags, link navigation, image pasting, and daily notes.
- markdown.nvim - Configurable tools for markdown editing including inline style toggling (bold/italic/code), table of contents generation, list management, link handling, and heading navigation.
- mkdnflow.nvim - A comprehensive markdown notebook/wiki plugin for fluent navigation with features like link following, to-do lists, table editing, section folding, buffer history navigation, and citation support.
- mdnotes.nvim - A Markdown note-taking plugin with WikiLink support, hyperlink management, asset cleanup, sequential buffer history, table creation, and automatic list continuation.
Table of Contents
- Installation
- Features
- Configuration
- Usage
- Keymaps Reference
- Customizing Keymaps
- Contributing
- Troubleshooting
Quick Start
Using lazy.nvim:
{
"yousefhadder/markdown-plus.nvim",
ft = "markdown",
opts = {},
}
That's it! The plugin will automatically activate with default keymaps when you open a markdown file.
v2.0 migration notes
vim.g.markdown_plusconfiguration was removed; callrequire("markdown-plus").setup(opts)(or lazy.nvimopts = {}) explicitly.- Default mappings now use
<localleader>instead of<leader>. - Internal API change for plugin integrators:
headers.parse_header()now expects(line, next_line)to support setext headings. - New toggle:
features.html_block_awareness = trueskips formatting/list/header operations inside HTML blocks. - New heading toggle:
<localleader>msswitches ATX/setext heading style (H1/H2). - New thematic-break commands:
<localleader>mh(insert) and<localleader>mH(cycle style). - Smart list outdent is enabled by default (
list.smart_outdent = true) for parent-aware marker continuation. - New list marker spacing: set
list.whitespace = "shiftwidth"(withlist.whitespace_width, default 4) to align list content to a fixed-width block instead of collapsing to a single space, keeping continuation lines aligned. Defaults to"single"(unchanged). Note: Markdown formatters that enforce single-space list markers (e.g. Prettier) will override this on format/save. - New list type toggling: the
<localleader>ltprefix toggles list types — press it, then a type key (uunordered,ttask,n1.,N1),la.,LA.,pa),PA),cclear) to toggle the current line or selection. These are real nested keymaps, so which-key shows the menu after the prefix. Works in normal and visual mode; each type also has a<Plug>mapping. Prefer an indefinitely-waiting picker? Bind<Plug>(MarkdownPlusToggleListPick)to<localleader>ltyourself. - New formatting escape toggle:
<localleader>me(visual mode) escapes/unescapes markdown punctuation. - Code block module is now first-class:
<localleader>mcinsert/wrap,]b/[bnavigate,<localleader>mCchange language. - Formatting defaults moved to avoid key collisions: strikethrough
<localleader>mS, inline code<localleader>m\``, highlightm= , clear formattingmF`. - If needed, set
vim.g.maplocalleader = "\\"(or your preferred key) before plugin setup.
Want to customize?
{
"yousefhadder/markdown-plus.nvim",
ft = "markdown",
opts = {
-- Your custom configuration here
},
}
See Configuration for all available options.
For keymaps, the supported config shape is:
opts = {
keymaps = {
enabled = true, -- master toggle for default markdown-plus keymaps
},
table = {
keymaps = {
enabled = true, -- table feature keymaps
},
},
}
Use <Plug>(MarkdownPlus...) mappings for custom remaps instead of undocumented top-level keymaps.* action keys.
Interop with other plugins
markdown-plus maps keys other plugins commonly own: <BS>, <CR>, <Tab>, <S-Tab>, <A-CR> (insert), o / O (normal) and the table cell navigation keys <A-h> / <A-j> / <A-k> / <A-l> (insert). In its own context — a list for the list keys, a table for the navigation keys — markdown-plus acts. Everywhere else it hands the key back instead of reimplementing the default behavior. No configuration required — this is always on.
For each such keypress outside markdown-plus's own context it resolves what would have run instead: a non-markdown-plus buffer-local mapping → a global mapping → the raw key. Resolution happens per keypress, so lazily-mapping plugins (InsertEnter and friends) are still found after setup. That keeps working:
- other plugins' mappings — mini.pairs
<BS>/<CR>, blink.cmp / nvim-cmp<CR>/<Tab>, LuaSnip and copilot.lua<Tab> - your own mappings for those keys, expression mappings included
- native behavior when nothing else is mapped: counts (
3o),autoindent,formatoptionscomment continuation,backspacesemantics - fenced code blocks, where markdown-plus never acts and always defers
<A-h/j/k/l>outside a table, where your window-mover, snippet or terminal mapping runs instead (with no mapping at all, the key still falls back to plain cursor movement)
Plugins that capture the mapping they displace (blink.cmp's fallback, copilot.lua's passthrough) are handled: handing our own <Plug> back terminates in native behavior rather than bouncing forever.
Taking over a key yourself
Map the key yourself and ask whether markdown-plus would have acted, via require("markdown-plus").in_list_context(kind) — kind is "enter" (default), "backspace" or "indent". It is read-only and cheap, so it is safe from an expression mapping.
mini.pairs <BS> with list-marker deletion preserved:
vim.keymap.set("i", "<BS>", function()
if require("markdown-plus").in_list_context("backspace") then
return "<Plug>(MarkdownPlusListBackspace)"
end
return require("mini.pairs").bs()
end, { expr = true, buffer = true })
Completion <CR>: confirm when the menu is open, continue the list otherwise:
vim.keymap.set("i", "<CR>", function()
if vim.fn.pumvisible() == 1 then
return "<C-y>"
end
if require("markdown-plus").in_list_context("enter") then
return "<Plug>(MarkdownPlusListEnter)"
end
return "<CR>"
end, { expr = true, buffer = true })
<Tab>: let a snippet jump win, keep list indentation:
vim.keymap.set("i", "<Tab>", function()
if vim.snippet.active({ direction = 1 }) then
return "<Cmd>lua vim.snippet.jump(1)<CR>"
end
if require("markdown-plus").in_list_context("indent") then
return "<Plug>(MarkdownPlusListIndent)"
end
return "<Tab>"
end, { expr = true, buffer = true })
Important
Ask the snippet engine explicitly, as above. A buffer-local mapping shadows the global
<Tab> your snippet plugin installed, and returning "<Tab>" from it does not fall through to
that global mapping — Neovim resolves the buffer-local one first, and its own recursion guard
then terminates in a literal tab. Swap the two vim.snippet calls for your plugin's equivalents
(require("luasnip").jumpable(1) / .jump(1), and so on) if you do not use the built-in engine.
This is only a concern when you own the key. markdown-plus's own default <Tab> resolves and
runs the mapping it displaced, global ones included — that is what the section above describes.
Each kind answers "would the handler act here?":
"enter"— broadest: on a list item line and on a wrapped continuation line under one."indent"— anywhere on a list item line."backspace"— narrowest: only where the marker would actually be removed (cursor in the marker zone, at the start of list content). Mid-content it isfalse, because<BS>there belongs to your pairs plugin, not to us.
Every kind is false inside a fenced code block, whatever the line looks like — a - item in a
```markdown fence included. markdown-plus never acts there, so neither does the predicate.
There is no dedicated <A-CR> kind. continue_list_content acts on any list item line, which is exactly "indent"'s scope — borrow that one when writing an <A-CR> recipe.
A pre-existing buffer-local mapping stops markdown-plus from installing its default for that key at all, so map buffer-locally (as above) or disable default keymaps first. To leave the table navigation keys entirely unmapped, set table = { keymaps = { insert_mode_navigation = false } }.
See :help markdown-plus-interop for the full reference.
License
MIT License - see LICENSE file for details.
Contributors ✨
Thanks goes to these wonderful people (emoji key):
This project follows the all-contributors specification. Contributions of any kind welcome!