Every syntax definition in md is a map %{handler_type => [{marker, properties}]}.
Each handler type defines how a particular kind of markup is recognized and transformed
during the single-pass parse. Below is the complete reference for every handler type
and all the properties each one accepts.
Settings
Settings are not a handler per se but configure global parser behavior. They live under
the :settings key in the syntax map.
outer(atom) -- default outer container tag wrapping bare text. Default::p.span(atom) -- default inline container tag. Default::span.linebreaks([binary]) -- list of binaries recognized as line breaks, tried in order. Default:[<<?\r, ?\n>>, <<?\n>>].empty_tags([atom]) -- tags that never need a closing counterpart (e.g.img,hr,br).requiring_attributes_tags([atom]) -- tags that are hidden/wrapped when they have no attributes (e.g.awithouthref).linewrap(boolean) -- whentrue, every newline inside a paragraph inserts a<br>tag instead of a literal newline character. Default:false.disclosure_range(Range.t()) -- allowed length range for disclosure reference labels. Default:3..5(override inDefaultto3..12).
%{
settings: %{
outer: :p,
span: :span,
linebreaks: [<<?\r, ?\n>>, <<?\n>>],
empty_tags: ~w|img hr br|a,
requiring_attributes_tags: ~w|a|a,
linewrap: false,
disclosure_range: 3..12
}
}Syntax
escape
Defines characters that, when immediately followed by another character, cause that next character to be treated as literal text (no markup interpretation).
Tuple format: {marker, properties}
marker(binary) -- the escape character.properties(map) -- currently unused; pass%{}.
escape: [
{<<92>>, %{}} # backslash
]Effect: \*not bold\* renders the literal asterisks.
comment
Defines comment blocks. Everything between the opening and closing markers is captured
but not rendered. The content is delivered to the listener as {:comment, stock}.
Properties:
closing(binary) -- closing marker. Default: same as opening marker.tag(atom) -- semantic tag name for listener events. Default::comment.
comment: [
{"<!--", %{closing: "-->"}}
]Effect: <!-- hidden text --> is stripped from output.
substitute
Simple text replacement. When the marker is encountered (outside raw mode), it is replaced with the given text in the output.
Properties:
text(binary) -- replacement string. Default:"".
substitute: [
{"<", %{text: "<"}},
{"&", %{text: "&"}}
]Effect: a bare < in the source becomes < in the output.
flush
Markers that immediately produce an empty (self-closing) element and break the current paragraph/block context.
Properties:
tag(atom | [atom]) -- tag(s) to emit. Required.rewind(false | true | :flip_flop) -- how to handle preceding content.false(default) -- just insert the tag.true-- rewind (close) any open elements first.:flip_flop-- close the current element and reopen a fresh one of the same kind (useful for---acting as a section separator).
attributes(map | nil) -- HTML attributes for the emitted tag.
flush: [
{"---", %{tag: :hr, rewind: :flip_flop}},
{" \n", %{tag: :br}}
]Effect: a line containing only --- emits <hr> and starts a new section.
block
Fenced blocks delimited by the same marker on opening and closing lines.
Content inside is parsed in the specified mode (:raw by default, meaning markup
is not interpreted). HTML entities inside raw blocks are auto-escaped.
Properties:
tag(atom | [atom]) -- tag(s) to wrap the block. When a list is given, tags are nested (first = outermost). Required.mode-- parsing mode inside the block. Default::raw.:raw-- content is treated as literal text (HTML-escaped).{:outer, tag}-- the block acts as an outer wrapper; content inside is parsed normally as top-level markup. The second element names the wrapper tag.
pop(map | nil) -- attribute extraction from the first content token. Keys are tag atoms; values describe which attribute to populate from the leading text.%{tag: :attribute_name}-- simple: first token becomes the attribute value.%{tag: [attribute: :attr, prefixes: ["", "lang-"]]}-- the first token is joined with each prefix (space-separated) to form the attribute value.
attributes(map | nil) -- static HTML attributes applied to the tag(s).
block: [
{"```", %{
tag: [:pre, :code],
pop: %{code: [attribute: :class, prefixes: ["", "lang-"]]}
}}
]Effect:
```elixir
IO.puts("hello")
```produces <pre><code class="elixir lang-elixir">IO.puts("hello")</code></pre>.
Outer block example (wraps parsed content in a <div>):
block: [
{":::", %{tag: [:div], mode: {:outer, :div}}}
]shift
Indentation-based blocks. A line starting with the shift marker enters a block; subsequent lines that also start with the marker continue the block. A line that does not start with the marker (or a blank line) closes it.
Properties:
tag(atom | [atom]) -- tag(s) to wrap the block. Required.mode-- parsing mode inside the block. Default:{:inner, :raw}.attributes(map | nil) -- HTML attributes applied to the tag(s).pop(map | nil) -- attribute extraction, same semantics as inblock.
shift: [
{" ", %{tag: [:div, :code], attributes: %{class: "pre"}}}
]Effect: four leading spaces produce a code block (similar to standard markdown indented code blocks).
Tab-based shift:
shift: [
{"\t", %{
tag: [:pre, :code],
pop: %{code: [attribute: :class, prefixes: ["", "lang-"]]}
}}
]Multiple shift markers can coexist in the same syntax definition; each marker is recognized independently.
pair
Two-part constructs where the first segment provides display content and the second provides metadata (href, src, title, etc.). This is the mechanism behind links, images, and abbreviations.
Properties:
tag(atom | [atom]) -- outer tag. Required.closing(binary) -- marker closing the first (content) segment. Required.inner_opening(binary) -- marker opening the second (metadata) segment. Required.inner_closing(binary) -- marker closing the second segment. Required.inner_tag(atom | true) -- tag for the inner element whenouteris{:tag, _}. Default:true(boolean flag, not used as a tag name whenouteris{:attribute, _}).outer-- determines how the two segments are combined:{:attribute, attr}-- the second segment becomes attributeattron the tag built from the first segment.{:attribute, {attr_content, attr_outer}}-- two attributes:attr_contentfrom the second segment,attr_outerfrom the first segment.{:tag, tag}-- the second segment is rendered as a child element of typetag; the first segment is also a child.{:tag, {tag, attr}}-- like{:tag, tag}but the second segment additionally populates attributeattron the inner tag.
disclosure_opening(binary | nil) -- if set, enables deferred/reference-style syntax where the second segment is a reference label enclosed in these markers.disclosure_closing(binary | nil) -- closing counterpart ofdisclosure_opening.attributes(map | nil) -- static HTML attributes.
pair: [
# Image: 
{"![", %{
tag: :img,
closing: "]",
inner_opening: "(",
inner_closing: ")",
outer: {:attribute, {:src, :title}}
}},
# Figure: !
{"!![", %{
tag: :figure,
closing: "]",
inner_opening: "(",
inner_closing: ")",
inner_tag: :img,
outer: {:tag, {:figcaption, :src}}
}},
# Abbreviation: ?[abbr](title)
{"?[", %{
tag: :abbr,
closing: "]",
inner_opening: "(",
inner_closing: ")",
outer: {:attribute, :title}
}},
# Link with optional reference: [text](href) or [text][ref]
{"[", %{
tag: :a,
closing: "]",
inner_opening: "(",
inner_closing: ")",
disclosure_opening: "[",
disclosure_closing: "]",
outer: {:attribute, :href}
}}
]Effect: [click me](https://example.com) produces <a href="https://example.com">click me</a>.
disclosure
Declares reference definitions that are resolved at the end of parsing.
Used together with pair entries that have disclosure_opening/disclosure_closing.
Properties:
until-- what terminates the reference value.:eol(default) -- end of line.binary-- a specific closing string.
disclosure: [
{":", %{until: :eol}}
]Effect: given [text][ref] in the document and [ref]: https://example.com as
a disclosure line, the link resolves at parse end.
paragraph
Block-level elements that start at the beginning of a line (after optional leading whitespace counted by the linefeed position). Supports nesting: repeated markers increase the nesting depth.
Properties:
tag(atom | [atom]) -- tag(s) for the element. When a list is given, tags nest (first = outermost). Required.mode-- parsing mode. Default:{:nested, tag, 1}(supports nesting).attributes(map | nil) -- HTML attributes.
paragraph: [
{"#", %{tag: :h1}},
{"##", %{tag: :h2}},
{"###", %{tag: :h3}},
{"####", %{tag: :h4}},
{"#####", %{tag: :h5}},
{"######", %{tag: :h6}},
# Nested blockquote:
{">", %{tag: [:blockquote, :p]}}
]Effect: ## Title produces <h2>Title</h2>. Repeated > nests blockquotes:
>> deep produces <blockquote><p><blockquote><p>deep</p></blockquote></p></blockquote>.
list
List item markers. Handles nesting via indentation tracking: deeper indentation opens a nested list, shallower indentation closes back up.
Properties:
tag(atom | [atom]) -- item tag(s). Required.outer(atom) -- container tag for the list. Default::ul.attributes(map | nil) -- HTML attributes.
list: [
{"- ", %{tag: :li, outer: :ul}},
{"* ", %{tag: :li, outer: :ul}},
{"+ ", %{tag: :li, outer: :ul}},
{"1. ", %{tag: :li, outer: :ol}},
{"2. ", %{tag: :li, outer: :ol}}
# ... up to the configured @ol_max
]Effect:
- first
- nested
- secondproduces <ul><li>first</li><ul><li>nested</li></ul><li>second</li></ul>.
brace
Inline (span-level) markers that wrap content. The same marker opens and closes
(unless closing is set to something different). Can optionally switch the parser
into :raw mode to prevent further markup interpretation inside.
Properties:
tag(atom | [atom]) -- tag(s) to wrap content. Required.mode(atom | nil) -- parsing mode inside the brace.nil(default) -- normal markdown parsing continues inside.:raw-- content is treated as literal text (no nested markup).
closing(binary) -- closing marker. Default: same as the opening marker.attributes(map | nil) -- HTML attributes.
brace: [
{"*", %{tag: :b}},
{"_", %{tag: :i}},
{"**", %{tag: :strong, attributes: %{class: "red"}}},
{"__", %{tag: :em}},
{"~", %{tag: :s}},
{"~~", %{tag: :del}},
{"`", %{tag: :code, mode: :raw, attributes: %{class: "code-inline"}}},
{"``", %{tag: :span, mode: :raw, attributes: %{class: "code-inline"}}},
{"[^", %{closing: "]", tag: :b, mode: :raw}}
]Effect: *bold* produces <b>bold</b>;
`code` produces <code class="code-inline">code</code> with no markup inside.
tag
Pass-through for literal HTML tags written as <tag>...</tag> in the source.
The parser recognizes the opening and closing forms and captures content between them.
Properties:
tag(atom) -- the Elixir atom for the tag. Default:String.to_atom(md).mode(atom) -- parsing mode inside. Default::md(normal parsing).closing(binary) -- closing tag string. Default:"</#{tag}>".attributes(map | nil) -- HTML attributes.
tag: [
{"sup", %{}},
{"sub", %{}},
{"kbd", %{}},
{"dl", %{}},
{"dt", %{}},
{"dd", %{}}
]Effect: <kbd>Ctrl</kbd> passes through as <kbd>Ctrl</kbd>.
matrix
Table/grid structures. A single marker separates cells; linebreaks separate rows.
The first row uses first_inner_tag (typically :th), subsequent rows use tag.
Properties:
tag(atom) -- cell tag for body rows. Default::div.outer(atom) -- outermost container tag. Default::div.inner(atom) -- row container tag. Default: same asouter.first_inner_tag(atom) -- cell tag for the header (first) row. Default: same astag.skip(binary | nil) -- when a line starts with this marker, the entire line is skipped (used for separator rows like|---|---|).extras(%{binary => atom}) -- additional markers recognized inside the matrix that switch to a different tag (e.g."#"for<caption>).attributes(map | nil) -- HTML attributes on the outer tag.
matrix: [
{"|", %{
tag: :td,
outer: :table,
inner: :tr,
first_inner_tag: :th,
skip: "|-",
extras: %{"#" => :caption}
}}
]Effect:
| Name | Age
|-
| Alice | 30
| Bob | 25
# Peopleproduces a <table> with <th> header cells, <td> body cells,
separator rows skipped, and <caption> from #.
magnet
Single-token markers that "magnetize" the immediately following word. When the marker is encountered, the parser collects characters until it hits a terminator (whitespace or specified punctuation), then passes the collected text through a transform function.
Properties:
transform((binary, binary -> Md.Listener.branch()) | module) -- transformation applied to the collected text. Either a function of arity 2(marker, text), or a module implementingMd.Transforms(which definesapply/2). Required.terminators-- what ends the magnet word. Default::ascii_punctuation.:ascii_punctuation-- any ASCII punctuation character.:utf8_punctuation-- any Unicode punctuation character.- list of codepoints (integers) -- specific characters, e.g.
[?,, ?., ?!]. []-- only whitespace terminates (greedy collection).
greedy-- controls whether the marker prefix and/or the delimiter suffix are included in the output passed to the transform:false(default) -- neither marker nor delimiter included.true/:both-- both marker and delimiter included.:left-- marker prefix included, delimiter not.:right-- delimiter included, marker not.
ignore_in([atom]) -- list of parent tag names inside which this magnet should not trigger. Default:[].
magnet: [
# Footnotes
{"[^", %{
transform: Md.Transforms.Footnote,
terminators: [?\]],
greedy: true,
ignore_in: [:img, :a, :figure, :abbr]
}},
# Twitter handles: @username
{"@", %{
transform: &Md.Transforms.TwitterHandle.apply/2,
terminators: [?,, ?., ?!, ??, ?:, ?;, ?[, ?], ?(, ?)],
ignore_in: [:img, :a, :figure, :abbr]
}},
# Auto-linked URLs
{"https://", %{
transform: Md.Transforms.Anchor,
terminators: [],
greedy: :left,
ignore_in: [:img, :a, :figure, :abbr]
}}
]Effect: @john produces <a href="https://twitter.com/john">@john</a>.
The Md.Transforms behaviour requires a single callback:
@callback apply(marker :: binary(), collected_text :: binary()) :: Md.Listener.branch()custom
Delegate parsing to an external module or function when a specific marker is encountered.
The handler receives the remaining input and current state, and must return
{continuation_input, updated_state}.
Tuple format: {marker, {handler, properties}}
Note the different tuple shape: the second element is itself a {handler, properties} tuple.
handler(module | (binary, state -> {binary, state})) -- either a module implementingparse/2or a function of arity 2.properties.rewind(boolean) -- whether to rewind (close open elements) before invoking the handler. Default:false.
custom: [
{"!!!", {MyApp.AlertParser, %{rewind: true}}}
]The handler module must implement:
@spec parse(binary(), Md.Parser.State.t()) :: {binary(), Md.Parser.State.t()}attributes
Inline attribute injection. When the opening marker appears immediately after an element, the parser switches to raw mode and collects key-value pairs until the closing marker. The attributes are then merged onto the preceding element.
Attribute syntax inside the markers supports:
key:valueorkey=valuepairs- Bare
key(treated askey: true) - Multiple pairs separated by spaces, commas, or pipes
Properties:
closing(binary) -- closing marker. Default: same as opening.
attributes: [
{"{{", %{closing: "}}"}}
]Effect: *bold*{{class:"highlight", id:main}} applies class="highlight" and id="main"
to the <b> tag.
Processing Order
Handlers are tried in the order they are expanded within the engine. This order matters when markers could be ambiguous. The expansion order is:
blockflushshifttagescapecommentmatrixdisclosuremagnetcustomsubstitutepairparagraphlistbraceattributes
Within each handler type, syntax items are sorted by descending marker length
during syntax merge, so longer markers are tried before shorter ones
(e.g. "**" before "*", "##" before "#").