Toggle Markdown Image Syntax with Treesitter

a table made of trees

A markdown image is an exclamation mark, followed by a pair of brackets, followed by a pair of parentheses. The image's alt-text goes in the brackets, and it's source URL goes in the parens.

![A table made of trees](markdown-images-treesitter.png)

While HTML images can have their attributes in any order, I've noticed that authors tend to put the src attribute before the alt attribute. One of the things I like about Markdown images is that they encourage the author to think about alt text first.

<img src="markdown-images-treesitter.png" alt="A table made of trees">

Markdown has limited HTML parsing capability, which means markdown files can contain HTML <img> tags and will work properly when they do, so mixing syntaxes in one file isn't a huge problem. In fact there are times when it's preferable to do so. But the terser and more text-oriented markdown syntax can be useful in different situations, like markdown tables, so I generally prefer to use it whenever I'm able.

The Wrists-Forward Approach

For these reasons, I often find myself converting HTML images to markdown syntax and vice-versa when working on blog posts, slide decks, or documentation. My typical approach using stock vim mappings would look something like this: For HTML to markdown, from the opening angle bracket,

  1. i![](Escape to create the markdown image.
  2. f"di" to get the alt text (assuming alt came first)
  3. 0f[pf<da< to paste it into the brackets
  4. repeat the process for the src.

If the src attribute comes first, as it often does, the motions will have to reflect that.

This task is simple enough, or at least, vim makes it quicker than a typical editor. However, this still involves eight discrete actions (insert, find, delete, move, find, put, find, delete). When you have a handful of images per file, and dozens of files to review, the effort stacks up quickly. And moreover, that's a lot of things to think about! We can do better. Treesitter knows the structure of our document. We can use it to find the alt text and source URL for us, and automatically convert back and forth between markdown and HTML.

Treesitter Queries

We start by writing custom queries for the html and markdown_inline treesitter parsers. Note that we have to use markdown_inline and not markdown for reasons.

    (tag_name) @_tag
    (attribute) @image.html.attr +) @image.html
    (#eq? @_tag "img")))
  (image_description) @image.markdown.alt
  (link_destination) @image.markdown.src) @image.markdown

These queries capture the img tag (@image.html) and all attributes (@image.html.attr) in the html case; for markdown, they will get the entire image (@image.markdown), the alt text (@image.markdown.alt), and the src (@image.markdown.src).

It's Lua Time!

Now let's define a custom command, called MarkdownToggleImages.

command = vim.api.nvim_create_user_command

command('MarkdownToggleImage', function()
  -- tbd
end, { desc = 'Toggle Markdown image syntax' })

Our function will work like so:

  1. determine the injected language at the cursor position;
  2. try to parse the image query for that language, and if we succeed;
  3. store a reference to the image node (so we can later replace it by range);
  4. in the HTML case:
    1. store the key/value pairs of all attributes;
    2. pick the src and alt values and store them with the node;
  5. in the markdown case:
    1. store the text values of the queried alt and src texts
  6. finally, replace the node's text with the opposite specie's template.

Because treesitter grammars can be nested (i.e. injected), neovim's treesitter integration provides a LanguageTree object which holds the collection of trees in a buffer and their relationships. The markdown parser can inject both the html and markdown_inline parsers, so let's get the specific language tree at the cursor position. nvim-treesitter provides a utility for this, which iterates through all the language trees in a range and gives back the syntax tree which contains the range.

local ts_utils = require'nvim-treesitter.ts_utils';
local row, col = unpack(vim.api.nvim_win_get_cursor(0))
local root, _, langtree = ts_utils.get_root_for_position(row - 1, col)
local lang = langtree:lang()
if lang == 'html' then
  return html_to_md(row - 1, root)
elseif lang == 'markdown_inline' then
  return md_to_html(row - 1, root)

Now that we have the language and concrete tree at the cursor position, we need to parse the query for that language it and run our images query (if it exists). Let's do the markdown version first because its' query gets the data directly

local function get_image_md(row, root)
  local query = vim.treesitter.query.get('markdown_inline', 'images')
  if not query then return end
  local node
  local alt
  local src
  for id, _node in query:iter_captures(root, 0, row, row + 1) do
    local cap = query.captures[id]
    if cap == 'image.markdown' then
      node = _node
    elseif cap == 'image.markdown.alt' then
      alt = get_node_text(_node, 0)
    elseif cap == 'image.markdown.src' then
      src = get_node_text(_node, 0)
  return node, alt, src

Now let's do the HTML version, which has to process arbitrary attributes because of the less sophisticated query

local function get_image_html(row, root)
  local query = vim.treesitter.query.get('html', 'images')
  if not query then return end
  local node
  local alt
  local src
  for id, _node in query:iter_captures(root, 0, row, row + 1) do
    local cap = query.captures[id]
    if cap == 'image.html' then
      node = _node
    elseif cap == 'image.html.attr' then
      local name, value
      for child in _node:iter_children() do
        local text = get_node_text(child, 0);
        local type = child:type()
        if type == 'attribute_name' then
          name = text
        elseif type == 'attribute_value' then
          value = text
        elseif type == 'quoted_attribute_value' then
          value = text:gsub('^[\'|"](.*)[\'|"]$', '%1')
      if name == 'alt' then
        alt = value
      elseif name == 'src' then
        src = value
  return node, alt, src

Now all we have to do is replace our node's range with a formatted template

local function replace_node_text(node, replacement)
  local srow, scol, erow, ecol = vim.treesitter.get_node_range(node)
  vim.api.nvim_buf_set_text(0, srow, scol, erow, ecol, { replacement })

local function html_to_md(row, root)
  local node, alt, src = get_image_html(row, root)
  if node then
    replace_node_text(node, ('![%s](%s)'):format(alt, src))

local function md_to_html(row, root)
  local node, alt, src = get_image_md(row, root)
  if node then
    replace_node_text(node, ('<img alt="%s" src="%s">'):format(alt, src))

If you find yourself doing this kind of thing often, I hope these snippets are helpful to you. I even added it to my dial.nvim config to make it as easy as <c-a>.