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.
| Concern | Regex | Tree-sitter AST |
|---|---|---|
| Bold / italic disambiguation | Fragile lookahead | Distinct node types |
| Nested lists | Requires explicit state | Natural via recursion depth |
| Code block boundaries | Tricky multiline match | code_fence_content node |
| Links vs. images | Lookahead for ! | Separate link / image types |
| Ordered vs. unordered lists | Regex heuristic | list_marker_dot vs list_marker_minus |
| Setext vs. ATX headings | Two separate passes | Two 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.
| Grammar | Node type | Org output |
|---|---|---|
| markdown | atx_heading | * heading |
| markdown | setext_heading | * / ** heading |
| markdown | fenced_code_block | #+begin_src lang … #+end_src |
| markdown | indented_code_block | #+begin_src … #+end_src |
| markdown | block_quote | #+begin_quote … #+end_quote |
| markdown | list / list_item | - item (recursive) |
| markdown | thematic_break | ----- |
| markdown | html_block | #+begin_export html |
| inline | strong_emphasis | *bold* |
| inline | emphasis | /italic/ |
| inline | code_span | ~code~ |
| inline | strikethrough | +strike+ |
| inline | link | [[url][text]] |
| inline | image | [[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.