md-to-org-treesit.el

Markdown → Org-mode via tree-sitter AST · Pure Elisp · Emacs 29+

Converting Markdown to Org-mode is a common need for anyone who works across both formats. The naïve approach — a pipeline of replace-regexp-in-string calls — quickly breaks down on bold-versus-italic disambiguation, nested lists, and fenced code blocks whose boundaries bleed into inline replacements. This document presents a cleaner solution: walk the tree-sitter parse tree and emit Org syntax node by node.

The result is two mutually recursive walkers, one for block-level constructs and one for inline spans, driven by the markdown and markdown-inline tree-sitter grammars respectively. No pandoc, no external processes, no markdown-mode regex variables — just treesit-parser-create and a pcase dispatch on node types.

Prerequisites

Two grammars are required. The markdown grammar handles block-level structure (headings, lists, code fences, blockquotes). The markdown-inline grammar handles inline spans (bold, italic, links, code spans) embedded within paragraph nodes. Both grammars live in the same repository under split_parser mode — the two source directories are tree-sitter-markdown/src and tree-sitter-markdown-inline/src.

Add both entries to treesit-language-source-alist, then invoke M-x treesit-install-language-grammar for each in turn.

;; Add to init.el, then M-x treesit-install-language-grammar for each
(add-to-list 'treesit-language-source-alist
             '(markdown
               "https://github.com/tree-sitter-grammars/tree-sitter-markdown"
               "split_parser" "tree-sitter-markdown/src"))

(add-to-list 'treesit-language-source-alist
             '(markdown-inline
               "https://github.com/tree-sitter-grammars/tree-sitter-markdown"
               "split_parser" "tree-sitter-markdown-inline/src"))

;; Verify: should return t
(treesit-language-available-p 'markdown)

Why the AST approach

Regex-based converters treat the document as a string and apply substitutions in a fixed order. This creates ordering dependencies: bold must be processed before italic, fenced blocks before inline code, and so on — and even then, edge cases remain. The canonical failure case is **foo *bar* baz**: a naïve italic replacement fires inside the bold span, leaving mismatched markers after the bold replacement runs. The tree-sitter parser produces a concrete syntax tree before any transformation. Each node knows its type, its span in the source, and its children. Conversion becomes a pure structural mapping with no ordering concerns.

ConcernRegexTree-sitter AST
Bold / italic disambiguationFragile lookaheadDistinct node types
Nested listsRequires explicit stateNatural via recursion depth
Code block boundariesTricky multiline matchcode_fence_content node
Links vs. imagesLookahead for !Separate link / image types
Ordered vs. unordered listsRegex heuristiclist_marker_dot vs list_marker_minus
Setext vs. ATX headingsTwo separate passesTwo separate node types

Node type reference

The walkers dispatch on treesit-node-type strings. The table below lists each node type handled and its Org-mode target. To inspect the live tree for any string:

(with-temp-buffer
 (insert "## Hello **world**\n")
 (treesit-node-string
  (treesit-parser-root-node
   (treesit-parser-create 'markdown))))

Node type names may vary between grammar versions. Always verify against your installed grammar before filing a bug.

GrammarNode typeOrg output
markdownatx_heading* heading
markdownsetext_heading* / ** heading
markdownfenced_code_block#+begin_src lang … #+end_src
markdownindented_code_block#+begin_src … #+end_src
markdownblock_quote#+begin_quote … #+end_quote
markdownlist / list_item- item (recursive)
markdownthematic_break-----
markdownhtml_block#+begin_export html
inlinestrong_emphasis*bold*
inlineemphasis/italic/
inlinecode_span~code~
inlinestrikethrough+strike+
inlinelink[[url][text]]
inlineimage[[url]]

Source

Preamble and helpers

Three thin wrappers over the treesit-node-* API reduce noise in the walkers below. The named argument to treesit-node-children filters out anonymous nodes such as punctuation tokens. The walkers pass t when they only care about named structural children, and omit it when they need all tokens (e.g. to strip delimiters).

Show source
;;; md-to-org-treesit.el --- Markdown -> Org via tree-sitter AST walk

(require 'treesit)
(require 'seq)
(require 'cl-lib)

;; -- Helpers -----------------------------------------------------------------

(defun md-ts--children (node &optional named)
  (treesit-node-children node named))

(defun md-ts--child-by-type (node type)
  (seq-find (lambda (c) (equal (treesit-node-type c) type))
            (md-ts--children node)))

(defun md-ts--child-matching (node regexp)
  (seq-find (lambda (c) (string-match-p regexp (treesit-node-type c)))
            (md-ts--children node)))

(defun md-ts--text (node)
  (treesit-node-text node))

Inline walker

md-ts--inline-to-org handles the markdown-inline grammar's node types. It is called recursively on each child of an inline node, and the results are concatenated. Emphasis nodes include their delimiter tokens (*, **, _) as children of type emphasis_delimiter. These are stripped by returning "" for that child type and wrapping the concatenated content in the Org marker.

Show source
;; -- Inline walker (markdown-inline grammar) --------------------------------

(defun md-ts--walk-inline-children (node &optional skip-types)
  "Walk NODE's children, interleaving converted children with raw text
for byte ranges not covered by any child. Children whose type is in
SKIP-TYPES contribute nothing (their byte range is dropped)."
  (let ((cursor (treesit-node-start node))
        (end    (treesit-node-end node))
        (parts  '()))
    (dolist (c (md-ts--children node))
      (let* ((cs   (treesit-node-start c))
             (ce   (treesit-node-end c))
             (type (treesit-node-type c)))
        (when (> cs cursor)
          (push (buffer-substring-no-properties cursor cs) parts))
        (unless (member type skip-types)
          (push (or (md-ts--inline-to-org c) "") parts))
        (setq cursor ce)))
    (when (> end cursor)
      (push (buffer-substring-no-properties cursor end) parts))
    (apply #'concat (nreverse parts))))

(defun md-ts--inline-text-to-org (text)
  "Parse TEXT with markdown-inline grammar and convert to org syntax."
  (with-temp-buffer
    (insert text)
    (let* ((parser (treesit-parser-create 'markdown-inline))
           (root   (treesit-parser-root-node parser)))
      (md-ts--walk-inline-children root))))

(defun md-ts--inline-to-org (node)
  "Convert an inline NODE (markdown-inline grammar) to org syntax."
  (when node
    (pcase (treesit-node-type node)

      ("inline"
       (if (md-ts--children node t)
           (md-ts--walk-inline-children node)
         (md-ts--inline-text-to-org (md-ts--text node))))

      ("strong_emphasis"
       (format "*%s*"
               (md-ts--walk-inline-children node '("emphasis_delimiter"))))

      ("emphasis"
       (format "/%s/"
               (md-ts--walk-inline-children node '("emphasis_delimiter"))))

      ("code_span"
       (format "~%s~"
               (md-ts--walk-inline-children
                node '("code_span_delimiter"))))

      ("strikethrough"
       (let ((text (md-ts--text node)))
         (format "+%s+"
                 (replace-regexp-in-string
                  "\\`~+\\|~+\\'" "" text))))

      ((or "link" "inline_link")
       (let* ((text-node (md-ts--child-by-type node "link_text"))
              (dest-node (md-ts--child-by-type node "link_destination"))
              (text (if text-node
                        (replace-regexp-in-string
                         "\\`\\[\\|\\]\\'" ""
                         (md-ts--text text-node))
                      ""))
              (dest (if dest-node (md-ts--text dest-node) "")))
         (format "[[%s][%s]]" dest text)))

      ("image"
       (let* ((dest-node (md-ts--child-by-type node "link_destination"))
              (dest (if dest-node (md-ts--text dest-node) "")))
         (format "[[%s]]" dest)))

      ("hard_line_break"  "\\\\\n")
      ("soft_line_break"  " ")
      ("backslash_escape" (substring (md-ts--text node) 1))
      ("entity_reference" (md-ts--text node))
      ("text"             (md-ts--text node))
      (_                  (md-ts--text node)))))

Block walker

The block walker handles the top-level markdown grammar. List items require their own helper because they recurse in two dimensions: across siblings (via md-ts--list-to-org) and into nested sublists (via the depth argument, which controls leading indentation). Ordered vs. unordered is detected by checking whether the first list item contains a list_marker_dot or list_marker_parenthesis child — the two marker types tree-sitter uses for 1. and 1) syntax respectively.

Show source
;; -- Block walker (markdown grammar) ----------------------------------------

(defun md-ts--list-item-to-org (node depth ordered-p)
  "Convert a list_item NODE to org, at DEPTH (0 = top level)."
  (let* ((indent     (make-string (* 2 depth) ?\s))
         (checked    (md-ts--child-by-type node "task_list_marker_checked"))
         (unchecked  (md-ts--child-by-type node "task_list_marker_unchecked"))
         (checkbox   (cond (checked   "[X] ")
                          (unchecked "[ ] ")
                          (t         "")))
         (bullet     (if ordered-p "1. " "- "))
         (skip-types '("list_marker_minus" "list_marker_plus" "list_marker_star"
                       "list_marker_dot" "list_marker_parenthesis"
                       "task_list_marker_checked" "task_list_marker_unchecked"))
         (content-nodes
          (seq-remove
           (lambda (c)
             (member (treesit-node-type c) skip-types))
           (md-ts--children node t)))
         (para      (seq-find
                      (lambda (c)
                        (equal (treesit-node-type c) "paragraph"))
                      content-nodes))
         (lists     (seq-filter
                      (lambda (c)
                        (equal (treesit-node-type c) "list"))
                      content-nodes))
         (inline    (when para
                      (md-ts--child-by-type para "inline")))
         (item-text (if inline
                        (string-trim (md-ts--inline-to-org inline))
                      ""))
         (nested    (mapconcat
                      (lambda (l)
                        (md-ts--list-to-org l (1+ depth)))
                      lists "")))
    (concat indent bullet checkbox item-text "\n" nested)))

(defun md-ts--list-to-org (node depth)
  "Convert a list NODE to org, tracking nesting DEPTH."
  (let* ((first-marker
          (md-ts--child-matching
           (car (md-ts--children node t))
           "list_marker_\\(dot\\|parenthesis\\)"))
         (ordered-p (not (null first-marker))))
    (mapconcat
     (lambda (item)
       (md-ts--list-item-to-org item depth ordered-p))
     (md-ts--children node t) "")))

(defun md-ts--fenced-code-to-org (node)
  (let* ((info    (md-ts--child-by-type node "info_string"))
         (lang    (if info (string-trim (md-ts--text info)) ""))
         (content (md-ts--child-by-type node "code_fence_content"))
         (code    (if content (md-ts--text content) "")))
    (format "#+begin_src %s\n%s#+end_src\n\n" lang code)))

(defun md-ts--block-quote-to-org (node)
  (let* ((inner-nodes
          (seq-remove
           (lambda (c)
             (equal (treesit-node-type c) "block_quote_marker"))
           (md-ts--children node t)))
         (content
          (mapconcat #'md-ts--block-to-org inner-nodes "")))
    (format "#+begin_quote\n%s\n#+end_quote\n\n"
            (string-trim content))))

(defun md-ts--block-to-org (node)
  "Convert a block-level NODE to an org-mode string."
  (pcase (treesit-node-type node)

    ((or "document" "section")
     (mapconcat #'md-ts--block-to-org
                (md-ts--children node t) ""))

    ("atx_heading"
     (let* ((marker (md-ts--child-matching node "atx_h[1-6]_marker"))
            (inline (md-ts--child-by-type node "inline"))
            (level  (cl-count ?# (md-ts--text marker)))
            (text   (if inline
                        (string-trim (md-ts--inline-to-org inline))
                      "")))
       (format "%s %s\n\n" (make-string level ?*) text)))

    ("setext_heading"
     (let* ((para  (md-ts--child-by-type node "paragraph"))
            (under (or (md-ts--child-by-type node "setext_h1_underline")
                       (md-ts--child-by-type node "setext_h2_underline")))
            (level (if (string-prefix-p "=" (md-ts--text under)) 1 2))
            (inline (when para
                      (md-ts--child-by-type para "inline")))
            (text  (if inline
                       (string-trim (md-ts--inline-to-org inline))
                     "")))
       (format "%s %s\n\n" (make-string level ?*) text)))

    ("paragraph"
     (let ((inline (md-ts--child-by-type node "inline")))
       (concat
        (if inline
            (md-ts--inline-to-org inline)
          (md-ts--text node))
        "\n\n")))

    ("fenced_code_block"   (md-ts--fenced-code-to-org node))

    ("indented_code_block"
     (let* ((raw  (md-ts--text node))
            (code (mapconcat
                    (lambda (line)
                      (replace-regexp-in-string
                       "^\\(?:    \\|\t\\)" "" line))
                    (split-string raw "\n") "\n")))
       (format "#+begin_src\n%s#+end_src\n\n" code)))

    ("block_quote"   (md-ts--block-quote-to-org node))
    ("list"          (concat (md-ts--list-to-org node 0) "\n"))
    ("thematic_break" "-----\n\n")
    ("html_block"
     (format "#+begin_export html\n%s#+end_export\n\n"
             (md-ts--text node)))
    ("link_reference_definition" "")
    (_ (md-ts--text node))))

Public API

Three entry points: a string function for programmatic use, an interactive buffer command, and a file-to-file converter.

Show source
;; -- Public API --------------------------------------------------------------

(defun md-to-org-treesit (md-string)
  "Convert MD-STRING from Markdown to Org-mode via tree-sitter AST.
Requires `markdown' and `markdown-inline' grammars to be installed."
  (unless (treesit-language-available-p 'markdown)
    (error "tree-sitter `markdown' grammar not available -- \
run M-x treesit-install-language-grammar"))
  (with-temp-buffer
    (insert md-string)
    (unless (bolp) (insert "\n"))
    (let* ((parser (treesit-parser-create 'markdown))
           (root   (treesit-parser-root-node parser)))
      (string-trim (md-ts--block-to-org root)))))

(defun md-to-org-treesit-region (beg end)
  "Convert current Markdown region from BEG to END to Org-mode in place using tree-sitter."
  (interactive "r")
  (let ((result (md-to-org-treesit (buffer-substring-no-properties beg end))))
    (delete-region beg end)
    (insert result)
    (message "Converted to Org via tree-sitter")))

(defun md-to-org-treesit-buffer ()
  "Convert current Markdown buffer to Org-mode in place using tree-sitter."
  (interactive)
  (let ((result (md-to-org-treesit (buffer-string))))
    (erase-buffer)
    (insert result)
    (org-mode)
    (message "Converted to Org via tree-sitter")))

(defun md-to-org-treesit-file (md-file org-file)
  "Convert MD-FILE to ORG-FILE using tree-sitter."
  (interactive "fMarkdown input: \nFOrg output: ")
  (let* ((md  (with-temp-buffer
                (insert-file-contents md-file)
                (buffer-string)))
         (org (md-to-org-treesit md)))
    (with-temp-file org-file
      (insert org))))

(provide 'md-to-org-treesit)
;;; md-to-org-treesit.el ends here

Limitations

Link reference definitions (e.g. [foo]: /url) are silently dropped — Org has no equivalent construct and any [foo][] references that relied on them will not resolve. A complete implementation would collect reference definitions in a first pass and substitute them during link node processing. For documents that use reference-style links heavily, pre-processing with markdown-link-at-pos from markdown-mode may be the pragmatic choice. HTML blocks are wrapped in #+begin_export html verbatim; inline HTML mixed with Markdown text is not unwrapped. Deeply nested blockquotes work recursively but are not tested beyond two levels.