README.md

August 5, 2026 · View on GitHub

GitHub release (latest by date) LuaRocks License: MIT

Tests Neovim Lua

GitHub stars GitHub issues

All Contributors

507DBB8A-996C-44B7-88BE-ABB7BC1BFD92_1_201_a

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

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_plus configuration was removed; call require("markdown-plus").setup(opts) (or lazy.nvim opts = {}) 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 = true skips formatting/list/header operations inside HTML blocks.
  • New heading toggle: <localleader>ms switches 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" (with list.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>lt prefix toggles list types — press it, then a type key (u unordered, t task, n 1., N 1), l a., L A., p a), P A), c clear) 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>lt yourself.
  • New formatting escape toggle: <localleader>me (visual mode) escapes/unescapes markdown punctuation.
  • Code block module is now first-class: <localleader>mc insert/wrap, ]b/[b navigate, <localleader>mC change language.
  • Formatting defaults moved to avoid key collisions: strikethrough <localleader>mS, inline code <localleader>m\``, highlight m=, clear formatting mF`.
  • 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, formatoptions comment continuation, backspace semantics
  • 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 is false, 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):

neo451
neo451

🤔
SuniRein
SuniRein

🐛 💻
Jaehaks
Jaehaks

🤔 🐛
Null
Null

🐛
赵泽文(Zhao Zev)
赵泽文(Zhao Zev)

🤔
coglinks
coglinks

🐛
Leo Yatsishin
Leo Yatsishin

🤔
Alexandre Desjardins
Alexandre Desjardins

🤔
J. Steinbach
J. Steinbach

🐛
ambaradan
ambaradan

💻
Stephen A. Davis
Stephen A. Davis

🤔
Freja Roberts
Freja Roberts

💻 🐛
m2228
m2228

🐛 🤔
Ben Marden
Ben Marden

🤔
jototland
jototland

🐛
edvinsyk
edvinsyk

🤔
Vsevolod
Vsevolod

🐛 💻
Henri K.
Henri K.

🤔
ArnallJM
ArnallJM

🤔
One-In-Dark
One-In-Dark

🐛

This project follows the all-contributors specification. Contributions of any kind welcome!