MDEx.Document (MDEx v0.9.4)
View SourceDocument is the core structure to store, manipulate, and render Markdown documents.
Tree
%MDEx.Document{
nodes: [
%MDEx.Paragraph{
nodes: [
%MDEx.Code{num_backticks: 1, literal: "Elixir"}
]
}
]
}
Each node may contain attributes and children nodes as in the example above where MDEx.Document
contains a MDEx.Paragraph
node which contains a MDEx.Code
node with the attributes :num_backticks
and :literal
.
You can check out each node's documentation in the Document Nodes
section, for example MDEx.HtmlBlock
.
The MDEx.Document
module represents the root of a document and implements several behaviours and protocols
to enable operations to fetch, update, and manipulate the document tree.
In these examples we will be using the ~MD sigil.
Tree Traversal
Understanding tree traversal is fundamental to working with MDEx documents, as it affects how all
Enum
functions, Access
operations, and other protocols behave.
The document tree is enumerated using depth-first pre-order traversal. This means:
- The parent node is visited first
- Then each child node is visited recursively
- Children are processed in the order they appear in the
:nodes
list
This traversal order affects all Enum
functions, including Enum.at/2
, Enum.map/2
, Enum.find/2
, and friends.
iex> doc = ~MD[# Hello]
iex> Enum.at(doc, 0)
%MDEx.Document{nodes: [%MDEx.Heading{nodes: [%MDEx.Text{literal: "Hello"}], level: 1, setext: false}]}
iex> Enum.at(doc, 1)
%MDEx.Heading{nodes: [%MDEx.Text{literal: "Hello"}], level: 1, setext: false}
iex> Enum.at(doc, 2)
%MDEx.Text{literal: "Hello"}
More complex traversal with nested elements:
iex> doc = ~MD[**bold** text]
iex> Enum.at(doc, 0)
%MDEx.Document{nodes: [%MDEx.Paragraph{nodes: [%MDEx.Strong{nodes: [%MDEx.Text{literal: "bold"}]}, %MDEx.Text{literal: " text"}]}]}
iex> Enum.at(doc, 1)
%MDEx.Paragraph{nodes: [%MDEx.Strong{nodes: [%MDEx.Text{literal: "bold"}]}, %MDEx.Text{literal: " text"}]}
iex> Enum.at(doc, 2)
%MDEx.Strong{nodes: [%MDEx.Text{literal: "bold"}]}
iex> Enum.at(doc, 3)
%MDEx.Text{literal: "bold"}
iex> Enum.at(doc, 4)
%MDEx.Text{literal: " text"}
Traverse and Update
You can also use the low-level MDEx.traverse_and_update/2
and MDEx.traverse_and_update/3
APIs
to traverse each node of the AST and either update the nodes or do some calculation with an accumulator.
Streaming
Experimental
Streaming is still experimental and subject to change in future releases.
It's disabled by default until the API is stabilized. Enable it with the option streaming: true
.
Streaming ties together MDEx.new(streaming: true)
, MDEx.Document.put_markdown/3
, and MDEx.Document.run/1
or MDEx.to_*
so you can feed complete or incomplete Markdown fragments into the Document which will be completed on demand to render valid output.
Typical usage:
- Start with
MDEx.new(streaming: true)
— the document enables streaming and buffers fragments. - Call
MDEx.Document.put_markdown/3
as fragments arrive — the text is buffered and parsing/rendering is deferred. - Call
MDEx.Document.run/1
or anyMDEx.to_*
— buffered fragments are parsed completing nodes to ensure valid output.
This is ideal for AI or chat apps where Markdown comes in bursts but must stay renderable.
For example, feeding **Fol
produces a temporary MDEx.Strong
node then adding low**
replaces it with the final content on the next run.
iex> doc = MDEx.new(streaming: true) |> MDEx.Document.put_markdown("**Fol")
iex> MDEx.to_html!(doc)
"<p><strong>Fol</strong></p>"
iex> doc |> MDEx.Document.put_markdown("low**") |> MDEx.to_html!()
"<p><strong>Follow</strong></p>"
You can find a demo application in examples/streaming.exs
.
Protocols
Enumerable
The Enumerable
protocol allows us to call Enum
functions to iterate over and manipulate the document tree.
All enumeration follows the depth-first traversal order described above.
Count the nodes in a document:
iex> doc = ~MD"""
...> # Languages
...>
...> `elixir`
...>
...> `rust`
...> """
iex> Enum.count(doc)
7
Count how many nodes have the :literal
attribute:
iex> doc = ~MD"""
...> # Languages
...>
...> `elixir`
...>
...> `rust`
...> """
iex> Enum.reduce(doc, 0, fn
...> %{literal: _literal}, acc -> acc + 1
...>
...> _node, acc -> acc
...> end)
3
Check if a node is member of the document:
iex> doc = ~MD"""
...> # Languages
...>
...> `elixir`
...>
...> `rust`
...> """
iex> Enum.member?(doc, %MDEx.Code{literal: "elixir", num_backticks: 1})
true
Map each node to its module name:
iex> doc = ~MD"""
...> # Languages
...>
...> `elixir`
...>
...> `rust`
...> """
iex> Enum.map(doc, fn %node{} -> inspect(node) end)
["MDEx.Document", "MDEx.Heading", "MDEx.Text", "MDEx.Paragraph", "MDEx.Code", "MDEx.Paragraph", "MDEx.Code"]
Collectable
The Collectable
protocol allows you to build documents by collecting nodes or merging multiple documents together.
This is particularly useful for programmatically constructing documents from various sources.
Merge two documents together using Enum.into/2
:
iex> first_doc = ~MD[# First Document]
iex> second_doc = ~MD[# Second Document]
iex> Enum.into(second_doc, first_doc)
%MDEx.Document{
nodes: [
%MDEx.Heading{nodes: [%MDEx.Text{literal: "First Document"}], level: 1, setext: false},
%MDEx.Heading{nodes: [%MDEx.Text{literal: "Second Document"}], level: 1, setext: false}
]
}
Collect individual nodes into a document:
iex> chunks = [
...> %MDEx.Text{literal: "Hello "},
...> %MDEx.Code{literal: "world", num_backticks: 1}
...> ]
iex> document = Enum.into(chunks, %MDEx.Document{})
%MDEx.Document{
nodes: [
%MDEx.Text{literal: "Hello "},
%MDEx.Code{literal: "world", num_backticks: 1}
]
}
iex> MDEx.to_html!(document)
"Hello <code>world</code>"
Build a document incrementally by collecting mixed content:
iex> chunks = [
...> %MDEx.Heading{nodes: [%MDEx.Text{literal: "Title"}], level: 1, setext: false},
...> %MDEx.Paragraph{nodes: []},
...> %MDEx.Text{literal: "Some text"},
...> %MDEx.ListItem{nodes: [%MDEx.Text{literal: "Item 1"}]},
...> %MDEx.Text{literal: " - WIP"},
...> ]
iex> document = Enum.into(chunks, %MDEx.Document{})
%MDEx.Document{
nodes: [
%MDEx.Heading{
level: 1,
nodes: [%MDEx.Text{literal: "Title"}],
setext: false
},
%MDEx.Paragraph{
nodes: [%MDEx.Text{literal: "Some text"}]
},
%MDEx.List{
bullet_char: "-",
delimiter: :period,
is_task_list: false,
list_type: :bullet,
marker_offset: 0,
nodes: [%MDEx.ListItem{nodes: [%MDEx.Text{literal: "Item 1 - WIP"}], list_type: :bullet, marker_offset: 0, padding: 2, start: 1, delimiter: :period, bullet_char: "-", tight: true, is_task_list: false}],
padding: 2,
start: 1,
tight: true
}
]
}
iex> MDEx.to_html!(document)
"<h1>Title</h1>\n<p>Some text</p>\n<ul>\n<li>Item 1 - WIP</li>\n</ul>"
Access
The Access
behaviour gives you the ability to fetch and update nodes using different types of keys.
Access operations also follow the depth-first traversal order when searching through nodes.
Access by Index
You can access nodes by their position in the depth-first traversal using integer indices:
iex> doc = ~MD[# Hello]
iex> doc[0]
%MDEx.Document{nodes: [%MDEx.Heading{nodes: [%MDEx.Text{literal: "Hello"}], level: 1, setext: false}]}
iex> doc[1]
%MDEx.Heading{nodes: [%MDEx.Text{literal: "Hello"}], level: 1, setext: false}
iex> doc[2]
%MDEx.Text{literal: "Hello"}
Negative indices access nodes from the end:
iex> doc = ~MD[# Hello **world**]
iex> doc[-1] # Last node
%MDEx.Text{literal: "world"}
Access by Node Type
Starting with a simple Markdown document, let's fetch only the text node by matching the MDEx.Text
node:
iex> ~MD[# Hello][%MDEx.Text{literal: "Hello"}]
[%MDEx.Text{literal: "Hello"}]
That's essentially the same as:
doc = %MDEx.Document{nodes: [%MDEx.Heading{nodes: [%MDEx.Text{literal: "Hello"}], level: 1, setext: false}]},
Enum.filter(
doc,
fn node -> node == %MDEx.Text{literal: "Hello"} end
)
The key can also be modules, atoms, and even functions! For example:
Fetch all Code nodes, either by MDEx.Code
module or the :code
atom representing the Code node:
iex> doc = ~MD"""
...> # Languages
...>
...> `elixir`
...>
...> `rust`
...> """
iex> doc[MDEx.Code]
[%MDEx.Code{num_backticks: 1, literal: "elixir"}, %MDEx.Code{num_backticks: 1, literal: "rust"}]
iex> doc[:code]
[%MDEx.Code{num_backticks: 1, literal: "elixir"}, %MDEx.Code{num_backticks: 1, literal: "rust"}]
Dynamically fetch Code nodes where the :literal
(node content) starts with "eli"
using a function to filter the result:
iex> doc = ~MD"""
...> # Languages
...>
...> `elixir`
...>
...> `rust`
...> """
iex> doc[fn node -> String.starts_with?(Map.get(node, :literal, ""), "eli") end]
[%MDEx.Code{num_backticks: 1, literal: "elixir"}]
That's the most flexible option, in case struct, modules, or atoms are not enough to match the node you want.
The Access protocol also allows us to update nodes that match a selector.
In the example below we'll capitalize the content of all MDEx.Code
nodes:
iex> doc = ~MD"""
...> # Languages
...>
...> `elixir`
...>
...> `rust`
...>
...> Continue...
...> """
iex> update_in(doc, [:document, Access.key!(:nodes), Access.all(), :code, Access.key!(:literal)], fn literal ->
...> String.upcase(literal)
...> end)
%MDEx.Document{
nodes: [
%MDEx.Heading{nodes: [%MDEx.Text{literal: "Languages"}], level: 1, setext: false},
%MDEx.Paragraph{nodes: [%MDEx.Code{num_backticks: 1, literal: "ELIXIR"}]},
%MDEx.Paragraph{nodes: [%MDEx.Code{num_backticks: 1, literal: "RUST"}]},
%MDEx.Paragraph{nodes: [%MDEx.Text{literal: "Continue..."}]}
]
}
String.Chars
Calling Kernel.to_string/1
will format it as CommonMark text:
iex> to_string(~MD[# Hello])
"# Hello"
Fragments (nodes without the parent %Document{}
) are also formatted:
iex> to_string(%MDEx.Heading{nodes: [%MDEx.Text{literal: "Hello"}], level: 1})
"# Hello"
Inspect
The Inspect
protocol provides two display formats for documents:
Tree format (default): Shows the document structure as a visual tree, making it easy to understand the hierarchy and relationships between nodes.
iex> ~MD[# Hello :smile:]
#MDEx.Document(3 nodes)<
└── 1 [heading] level: 1, setext: false
├── 2 [text] literal: "Hello "
└── 3 [short_code] code: "smile", emoji: "😄"
>
Struct format: Shows the raw struct representation, useful for debugging and testing. To enable this format:
iex> Application.put_env(:mdex, :inspect_format, :struct)
iex> ~MD[# Hello :smile:]
%MDEx.Document{
nodes: [
%MDEx.Heading{
nodes: [%MDEx.Text{literal: "Hello "}, %MDEx.ShortCode{code: "smile", emoji: "😄"}],
level: 1,
setext: false
}
],
# ... other fields
}
The struct format is particularly useful in tests where you need to see exact differences between expected and actual values. You can set this in your test/test_helper.exs
:
Application.put_env(:mdex, :inspect_format, :struct)
Pipeline and Plugins
MDEx.Document is a Req-like API to transform Markdown documents through a series of steps in a pipeline.
Its main use case it to enable plugins, for example:
markdown = """
# Project Diagram
```mermaid
graph TD
A[Enter Chart Definition] --> B(Preview)
B --> C{decide}
C --> D[Keep]
C --> E[Edit Definition]
E --> B
D --> F[Save Image and Code]
F --> B
```
"""
MDEx.new(markdown: markdown)
|> MDExMermaid.attach(mermaid_version: "11")
|> MDEx.to_html!()
To understand how it works, let's write that Mermaid plugin.
Writing Plugins
Let's start with a simple plugin as example to render Mermaid diagrams.
In order to render Mermaid diagrams, we need to inject a <script>
into the document,
as outlined in their docs:
<script type="module">
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
mermaid.initialize({ startOnLoad: true });
</script>
Note that the package version is specified in the URL, so we'll add an option
:mermaid_version
to the plugin to let users specify the version they want to use.
By default, we'll use the latest version:
MDEx.new() |> MDExMermaid.attach()
But users can override it:
MDEx.new() |> MDExMermaid.attach(mermaid_version: "11")
Let's get into the actual code, with comments to explain each part:
defmodule MDExMermaid do
alias MDEx.Document
@latest_version "11"
def attach(document, options \ []) do
document
# register option with prefix `:mermaid_` to avoid conflicts with other plugins
|> Document.register_options([:mermaid_version])
# merge all options given by users
|> Document.put_options(options)
# actual steps to manipulate the document
# see respective Document functions for more info
|> Document.append_steps(enable_unsafe: &enable_unsafe/1)
|> Document.append_steps(inject_script: &inject_script/1)
|> Document.append_steps(update_code_blocks: &update_code_blocks/1)
end
# to render raw html and <script> tags
defp enable_unsafe(document) do
Document.put_render_options(document, unsafe: true)
end
defp inject_script(document) do
version = Document.get_option(document, :mermaid_version, @latest_version)
script_node =
%MDEx.HtmlBlock{
literal: """
<script type="module">
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@#{version}/dist/mermaid.esm.min.mjs';
mermaid.initialize({ startOnLoad: true });
</script>
"""
}
Document.put_node_in_document_root(document, script_node)
end
defp update_code_blocks(document) do
selector = fn
%MDEx.CodeBlock{info: "mermaid"} -> true
_ -> false
end
Document.update_nodes(
document,
selector,
&%MDEx.HtmlBlock{literal: "<pre class="mermaid">#{&1.literal}</pre>", nodes: &1.nodes}
)
end
end
Now we can attach/1
that plugin into any MDEx document to render Mermaid diagrams.
Practical Examples
Here are some common patterns for working with MDEx documents that combine the protocols described above.
Update all code block nodes filtered by the selector
function
Add line "// Modified" in Rust block codes:
iex> doc = ~MD"""
...> # Code Examples
...>
...> ```elixir
...> def hello do
...> :world
...> end
...> ```
...>
...> ```rust
...> fn main() {
...> println!("Hello");
...> }
...> ```
...> """
iex> selector = fn
...> %MDEx.CodeBlock{info: "rust"} -> true
...> _ -> false
...> end
iex> update_in(doc, [:document, Access.key!(:nodes), Access.all(), selector], fn node ->
...> %{node | literal: "// Modified\n" <> node.literal}
...> end)
%MDEx.Document{
nodes: [
%MDEx.Heading{
nodes: [%MDEx.Text{literal: "Code Examples"}],
level: 1,
setext: false
},
%MDEx.CodeBlock{
info: "elixir",
literal: "def hello do\n :world\nend\n"
},
%MDEx.CodeBlock{
info: "rust",
literal: "// Modified\nfn main() {\n println!(\"Hello\");\n}\n"
}
]
}
Collect headings by level
iex> doc = ~MD"""
...> # Main Title
...>
...> ## Section 1
...>
...> ### Subsection
...>
...> ## Section 2
...> """
iex> Enum.reduce(doc, %{}, fn
...> %MDEx.Heading{level: level, nodes: [%MDEx.Text{literal: text}]}, acc ->
...> Map.update(acc, level, [text], &[text | &1])
...> _node, acc -> acc
...> end)
%{
1 => ["Main Title"],
2 => ["Section 2", "Section 1"],
3 => ["Subsection"]
}
Extract and transform task list items
iex> doc = ~MD"""
...> # Todo List
...>
...> - [ ] Buy groceries
...> - [x] Call mom
...> - [ ] Read book
...> """
iex> Enum.map(doc, fn
...> %MDEx.TaskItem{checked: checked, nodes: [%MDEx.Paragraph{nodes: [%MDEx.Text{literal: text}]}]} ->
...> {checked, text}
...> _ -> nil
...> end)
...> |> Enum.reject(&is_nil/1)
[
{false, "Buy groceries"},
{true, "Call mom"},
{false, "Read book"}
]
Bump all heading levels, except level 6
iex> doc = ~MD"""
...> # Main Title
...>
...> ## Subtitle
...>
...> ###### Notes
...> """
iex> selector = fn
...> %MDEx.Heading{level: level} when level < 6 -> true
...> _ -> false
...> end
iex> update_in(doc, [:document, Access.key!(:nodes), Access.all(), selector], fn node ->
...> %{node | level: node.level + 1}
...> end)
%MDEx.Document{
nodes: [
%MDEx.Heading{nodes: [%MDEx.Text{literal: "Main Title"}], level: 2, setext: false},
%MDEx.Heading{nodes: [%MDEx.Text{literal: "Subtitle"}], level: 3, setext: false},
%MDEx.Heading{nodes: [%MDEx.Text{literal: "Notes"}], level: 6, setext: false}
]
}
Summary
Types
Fragment of a Markdown document, a single node. May contain children nodes.
Options to customize the parsing and rendering of Markdown documents.
List of comrak parse options.
List of comrak render options.
List of ammonia options.
Selector used to match nodes in the document.
Step in a pipeline.
Syntax Highlight code blocks using autumn.
Tree root of a Markdown document, including all children nodes.
Functions
Appends steps to the end of the existing document's step list.
Returns the default :extension
options.
Returns all default options.
Returns the default :parse
options.
Returns the default :render
options.
Returns the default :sanitize
options.
Returns the default :syntax_highlight
options.
Callback implementation for Access.fetch/2
.
Callback implementation for Access.get_and_update/3
.
Retrieves an option value from the document.
Retrieves a private value from the document.
Retrieves one of the sanitize_options/0
options from the document.
Halts the document pipeline execution.
Halts the document pipeline execution with an exception.
Returns true
if the document has the :sanitize
option set, otherwise false
.
Callback implementation for Access.fetch/2
.
Prepends steps to the beginning of the existing document's step list.
Updates the document's :extension
options.
Adds markdown
chunks into the document
buffer.
Inserts node
into the document root at the specified position
.
Merges options into the document options.
Updates the document's :parse
options.
Stores a value in the document's private storage.
Updates the document's :render
options.
Updates the document's :sanitize
options.
Updates the document's :syntax_highlight
options.
Registers a list of valid options that can be used by steps in the document pipeline.
Executes the document pipeline.
Updates all nodes in the document that match selector
.
Updates a value in the document's private storage using a function.
Wraps nodes in a MDEx.Document
.
Types
@type extension_options() :: [ strikethrough: boolean(), tagfilter: boolean(), table: boolean(), autolink: boolean(), tasklist: boolean(), superscript: boolean(), header_ids: binary() | nil, footnotes: boolean(), description_lists: boolean(), front_matter_delimiter: binary() | nil, multiline_block_quotes: boolean(), alerts: boolean(), math_dollars: boolean(), math_code: boolean(), shortcodes: boolean(), wikilinks_title_after_pipe: boolean(), wikilinks_title_before_pipe: boolean(), underline: boolean(), subscript: boolean(), spoiler: boolean(), greentext: boolean(), image_url_rewriter: binary() | nil, link_url_rewriter: binary() | nil, cjk_friendly_emphasis: boolean() ]
List of comrak extension options.
Example
MDEx.to_html!("~~strikethrough~~", extension: [strikethrough: true])
#=> "<p><del>strikethrough</del></p>"
@type md_node() :: MDEx.FrontMatter.t() | MDEx.BlockQuote.t() | MDEx.List.t() | MDEx.ListItem.t() | MDEx.DescriptionList.t() | MDEx.DescriptionItem.t() | MDEx.DescriptionTerm.t() | MDEx.DescriptionDetails.t() | MDEx.CodeBlock.t() | MDEx.HtmlBlock.t() | MDEx.Paragraph.t() | MDEx.Heading.t() | MDEx.ThematicBreak.t() | MDEx.FootnoteDefinition.t() | MDEx.FootnoteReference.t() | MDEx.Table.t() | MDEx.TableRow.t() | MDEx.TableCell.t() | MDEx.Text.t() | MDEx.TaskItem.t() | MDEx.SoftBreak.t() | MDEx.LineBreak.t() | MDEx.Code.t() | MDEx.HtmlInline.t() | MDEx.Raw.t() | MDEx.Emph.t() | MDEx.Strong.t() | MDEx.Strikethrough.t() | MDEx.Superscript.t() | MDEx.Link.t() | MDEx.Image.t() | MDEx.ShortCode.t() | MDEx.Math.t() | MDEx.MultilineBlockQuote.t() | MDEx.Escaped.t() | MDEx.WikiLink.t() | MDEx.Underline.t() | MDEx.Subscript.t() | MDEx.SpoileredText.t() | MDEx.EscapedTag.t() | MDEx.Alert.t()
Fragment of a Markdown document, a single node. May contain children nodes.
@type options() :: [ extension: extension_options(), parse: parse_options(), render: render_options(), syntax_highlight: syntax_highlight_options() | nil, sanitize: sanitize_options() | nil, streaming: boolean() ]
Options to customize the parsing and rendering of Markdown documents.
Examples
Enable the
table
extension:MDEx.to_html!(""" | lang | |------| | elixir | """, extension: [table: true] )
Syntax highlight using inline style and the
github_light
theme:MDEx.to_html!(""" ## Code Example ```elixir Atom.to_string(:elixir) ``` """, syntax_highlight: [ formatter: {:html_inline, theme: "github_light"} ])
Sanitize HTML output, in this example disallow
<a>
tags:MDEx.to_html!(""" ## Links won't be displayed <a href="https://example.com">Example</a> ``` """, sanitize: [ rm_tags: ["a"], ])
Options
:extension
(keyword/0
) - Enable extensions. See comrak's ExtensionOptions for more info and examples. The default value is[]
.:strikethrough
(boolean/0
) - Enables the strikethrough extension from the GFM spec. The default value isfalse
.:tagfilter
(boolean/0
) - Enables the tagfilter extension from the GFM spec. The default value isfalse
.:table
(boolean/0
) - Enables the table extension from the GFM spec. The default value isfalse
.:autolink
(boolean/0
) - Enables the autolink extension from the GFM spec. The default value isfalse
.:tasklist
(boolean/0
) - Enables the task list extension from the GFM spec. The default value isfalse
.:superscript
(boolean/0
) - Enables the superscript Comrak extension. The default value isfalse
.:header_ids
- Enables the header IDs Comrak extension. The default value isnil
.:footnotes
(boolean/0
) - Enables the footnotes extension per cmark-gfm The default value isfalse
.:description_lists
(boolean/0
) - Enables the description lists extension. The default value isfalse
.:front_matter_delimiter
- Enables the front matter extension. The default value isnil
.:multiline_block_quotes
(boolean/0
) - Enables the multiline block quotes extension. The default value isfalse
.:alerts
(boolean/0
) - Enables GitHub style alerts. The default value isfalse
.:math_dollars
(boolean/0
) - Enables math using dollar syntax. The default value isfalse
.:math_code
(boolean/0
) - Enables the math code extension from the GFM spec. The default value isfalse
.:shortcodes
(boolean/0
) - Phrases wrapped inside of ':' blocks will be replaced with emojis. The default value isfalse
.:wikilinks_title_after_pipe
(boolean/0
) - Enables wikilinks using title after pipe syntax. The default value isfalse
.:wikilinks_title_before_pipe
(boolean/0
) - Enables wikilinks using title before pipe syntax. The default value isfalse
.:underline
(boolean/0
) - Enables underlines using double underscores. The default value isfalse
.:subscript
(boolean/0
) - Enables subscript text using single tildes. The default value isfalse
.:spoiler
(boolean/0
) - Enables spoilers using double vertical bars. The default value isfalse
.:greentext
(boolean/0
) - Requires at least one space after a > character to generate a blockquote, and restarts blockquote nesting across unique lines of input. The default value isfalse
.:image_url_rewriter
- Wraps embedded image URLs using a string template.Example:
Given this image

and this rewriter:image_url_rewriter: "https://example.com?url={@url}"
Renders
<p><img src="https://example.com?url=http://unsafe.com/image.png" alt="alt text" /></p>
Notes:
- Assign
@url
is always passed to the template. - Function callback is not supported, only string templates. Transform the Document AST for more complex cases.
The default value is
nil
.- Assign
:link_url_rewriter
- Wraps link URLs using a string template.Example:
Given this link
[my link](http://unsafe.example.com/bad)
and this rewriter:link_url_rewriter: "https://safe.example.com/norefer?url={@url}"
Renders
<p><a href="https://safe.example.com/norefer?url=http://unsafe.example.com/bad">my link</a></p>
Notes:
- Assign
@url
is always passed to the template. - Function callback is not supported, only string templates. Transform the Document AST for more complex cases.
The default value is
nil
.- Assign
:cjk_friendly_emphasis
(boolean/0
) - Recognizes many emphasis that appear in CJK contexts but are not recognized by plain CommonMark. The default value isfalse
.
:parse
(keyword/0
) - Configure parsing behavior. See comrak's ParseOptions for more info and examples. The default value is[]
.:smart
(boolean/0
) - Punctuation (quotes, full-stops and hyphens) are converted into 'smart' punctuation. The default value isfalse
.:default_info_string
- The default info string for fenced code blocks. The default value isnil
.:relaxed_tasklist_matching
(boolean/0
) - Whether or not a simplex
orX
is used for tasklist or any other symbol is allowed. The default value isfalse
.:relaxed_autolinks
(boolean/0
) - Relax parsing of autolinks, allow links to be detected inside brackets and allow all url schemes. It is intended to allow a very specific type of autolink detection, such as[this http://and.com that]
or{http://foo.com}
, on a best can basis. The default value istrue
.
:render
(keyword/0
) - Configure rendering behavior. See comrak's RenderOptions for more info and examples. The default value is[]
.:hardbreaks
(boolean/0
) - Soft line breaks in the input translate into hard line breaks in the output. The default value isfalse
.:github_pre_lang
(boolean/0
) - GitHub-style<pre lang="xyz">
is used for fenced code blocks with info tags. The default value isfalse
.:full_info_string
(boolean/0
) - Enable full info strings for code blocks. The default value isfalse
.:width
(integer/0
) - The wrap column when outputting CommonMark. The default value is0
.:unsafe
(boolean/0
) - Allow rendering of raw HTML and potentially dangerous links. The default value isfalse
.:escape
(boolean/0
) - Escape raw HTML instead of clobbering it. The default value isfalse
.:list_style
- Set the type of bullet list marker to use. Either one of:dash
,:plus
, or:star
. The default value is:dash
.:sourcepos
(boolean/0
) - Include source position attributes in HTML and XML output. The default value isfalse
.:escaped_char_spans
(boolean/0
) - Wrap escaped characters in a<span>
to allow any post-processing to recognize them. The default value isfalse
.:ignore_setext
(boolean/0
) - Ignore setext headings in input. The default value isfalse
.:ignore_empty_links
(boolean/0
) - Ignore empty links in input. The default value isfalse
.:gfm_quirks
(boolean/0
) - Enables GFM quirks in HTML output which break CommonMark compatibility. The default value isfalse
.:prefer_fenced
(boolean/0
) - Prefer fenced code blocks when outputting CommonMark. The default value isfalse
.:figure_with_caption
(boolean/0
) - Render the image as a figure element with the title as its caption. The default value isfalse
.:tasklist_classes
(boolean/0
) - Add classes to the output of the tasklist extension. This allows tasklists to be styled. The default value isfalse
.:ol_width
(integer/0
) - Render ordered list with a minimum marker width. Having a width lower than 3 doesn't do anything. The default value is1
.:experimental_minimize_commonmark
(boolean/0
) - Minimise escapes used in CommonMark output (-t commonmark
) by removing each individually and seeing if the resulting document roundtrips. Brute-force and expensive, but produces nicer output. Note that the result may not in fact be minimal. The default value isfalse
.
:syntax_highlight
- Apply syntax highlighting to code blocks.Examples:
syntax_highlight: [formatter: {:html_inline, theme: "github_dark"}] syntax_highlight: [formatter: {:html_linked, theme: "github_light"}]
See Autumn for more info and examples.
The default value is
[formatter: {:html_inline, [theme: "onedark"]}]
.:sanitize
- Cleans HTML using ammonia after rendering.It's disabled by default but you can enable its conservative set of default options as:
[sanitize: MDEx.Document.default_sanitize_options()]
Or customize one of the options. For example, to disallow
<a>
tags:[sanitize: [rm_tags: ["a"]]]
In the example above it will append
rm_tags: ["a"]
into the default set of options, essentially the same as:sanitize = Keyword.put(MDEx.Document.default_sanitize_options(), :rm_tags, ["a"]) [sanitize: sanitize]
See the Safety section for more info.
The default value is
nil
.:streaming
(boolean/0
) - Enables streaming (experimental). The default value isfalse
.
@type parse_options() :: [ smart: boolean(), default_info_string: binary() | nil, relaxed_tasklist_matching: boolean(), relaxed_autolinks: boolean() ]
List of comrak parse options.
Example
MDEx.to_html!(""Hello" -- world...", parse: [smart: true])
#=> "<p>“Hello” – world…</p>"
@type render_options() :: [ hardbreaks: boolean(), github_pre_lang: boolean(), full_info_string: boolean(), width: integer(), unsafe: boolean(), escape: boolean(), list_style: term(), sourcepos: boolean(), escaped_char_spans: boolean(), ignore_setext: boolean(), ignore_empty_links: boolean(), gfm_quirks: boolean(), prefer_fenced: boolean(), figure_with_caption: boolean(), tasklist_classes: boolean(), ol_width: integer(), experimental_minimize_commonmark: boolean() ]
List of comrak render options.
Example
MDEx.to_html!("<script>alert('xss')</script>", render: [unsafe: true])
#=> "<script>alert('xss')</script>"
@type sanitize_options() :: [ tags: [binary()], add_tags: [binary()], rm_tags: [binary()], clean_content_tags: [binary()], add_clean_content_tags: [binary()], rm_clean_content_tags: [binary()], tag_attributes: %{optional(binary()) => [binary()]}, add_tag_attributes: %{optional(binary()) => [binary()]}, rm_tag_attributes: %{optional(binary()) => [binary()]}, tag_attribute_values: %{ optional(binary()) => %{optional(binary()) => [binary()]} }, add_tag_attribute_values: %{ optional(binary()) => %{optional(binary()) => [binary()]} }, rm_tag_attribute_values: %{ optional(binary()) => %{optional(binary()) => [binary()]} }, set_tag_attribute_values: %{ optional(binary()) => %{optional(binary()) => binary()} }, set_tag_attribute_value: %{ optional(binary()) => %{optional(binary()) => binary()} }, rm_set_tag_attribute_value: %{optional(binary()) => binary()}, generic_attribute_prefixes: [binary()], add_generic_attribute_prefixes: [binary()], rm_generic_attribute_prefixes: [binary()], generic_attributes: [binary()], add_generic_attributes: [binary()], rm_generic_attributes: [binary()], url_schemes: [binary()], add_url_schemes: [binary()], rm_url_schemes: [binary()], url_relative: term() | {atom(), binary()} | {atom(), {binary(), binary()}}, link_rel: binary() | nil, allowed_classes: %{optional(binary()) => [binary()]}, add_allowed_classes: %{optional(binary()) => [binary()]}, rm_allowed_classes: %{optional(binary()) => [binary()]}, strip_comments: boolean(), id_prefix: binary() | nil ]
List of ammonia options.
Example
iex> MDEx.to_html!("<h1>Title</h1><p>Content</p>", sanitize: [rm_tags: ["h1"]], render: [unsafe: true])
"Title<p>Content</p>"
Selector used to match nodes in the document.
Valid selectors can be the module or struct, an atom representing the node name, or a function that receives a node and returns a boolean.
See MDEx.Document
for more info and examples.
@type step() :: (t() -> t()) | (t() -> {t(), Exception.t()}) | (t() -> {module(), atom(), [term()]})
Step in a pipeline.
It's a function that receives a MDEx.Document.t/0
struct and must return either one of the following:
- a
MDEx.Document.t/0
struct - a tuple with a
MDEx.Document.t/0
struct and anException.t/0
as{document, exception}
- a tuple with a module, function and arguments which will be invoked with
apply/3
@type syntax_highlight_options() :: [{:formatter, Autumn.formatter()}]
Syntax Highlight code blocks using autumn.
Example
MDEx.to_html!("""
...> ```elixir
...> {:mdex, "~> 0.1"}
...> ```
...> """, syntax_highlight: [formatter: {:html_inline, theme: "nord"}])
#=> <pre class="athl" style="color: #d8dee9; background-color: #2e3440;"><code class="language-elixir" translate="no" tabindex="0"><span class="line" data-line="1"><span style="color: #88c0d0;">{</span><span style="color: #ebcb8b;">:mdex</span><span style="color: #88c0d0;">,</span> <span style="color: #a3be8c;">"~> 0.1"</span><span style="color: #88c0d0;">}</span>
#=> </span></code></pre>
@type t() :: %MDEx.Document{ buffer: term(), current_steps: term(), halted: boolean(), nodes: [md_node()], options: options(), private: %{}, registered_options: MapSet.t(), steps: [step()] }
Tree root of a Markdown document, including all children nodes.
Functions
Appends steps to the end of the existing document's step list.
Examples
iex> document = MDEx.new()
iex> document = MDEx.Document.append_steps(
...> document,
...> enable_tables: fn doc -> MDEx.Document.put_extension_options(doc, table: true) end
...> )
iex> document
...> |> MDEx.Document.run()
...> |> MDEx.Document.get_option(:extension)
...> |> Keyword.get(:table)
true
@spec default_extension_options() :: extension_options()
Returns the default :extension
options.
[
cjk_friendly_emphasis: false,
link_url_rewriter: nil,
image_url_rewriter: nil,
greentext: false,
spoiler: false,
subscript: false,
underline: false,
wikilinks_title_before_pipe: false,
wikilinks_title_after_pipe: false,
shortcodes: false,
math_code: false,
math_dollars: false,
alerts: false,
multiline_block_quotes: false,
front_matter_delimiter: nil,
description_lists: false,
footnotes: false,
header_ids: nil,
superscript: false,
tasklist: false,
autolink: false,
table: false,
tagfilter: false,
strikethrough: false
]
@spec default_options() :: options()
Returns all default options.
[
streaming: false,
sanitize: nil,
syntax_highlight: [
formatter: {:html_inline,
[
header: nil,
highlight_lines: nil,
include_highlights: false,
italic: false,
pre_class: nil,
theme: "onedark"
]}
],
render: [
experimental_minimize_commonmark: false,
ol_width: 1,
tasklist_classes: false,
figure_with_caption: false,
prefer_fenced: false,
gfm_quirks: false,
ignore_empty_links: false,
ignore_setext: false,
escaped_char_spans: false,
sourcepos: false,
list_style: :dash,
escape: false,
unsafe: false,
width: 0,
full_info_string: false,
github_pre_lang: false,
hardbreaks: false
],
parse: [
relaxed_autolinks: true,
relaxed_tasklist_matching: false,
default_info_string: nil,
smart: false
],
extension: [
cjk_friendly_emphasis: false,
link_url_rewriter: nil,
image_url_rewriter: nil,
greentext: false,
spoiler: false,
subscript: false,
underline: false,
wikilinks_title_before_pipe: false,
wikilinks_title_after_pipe: false,
shortcodes: false,
math_code: false,
math_dollars: false,
alerts: false,
multiline_block_quotes: false,
front_matter_delimiter: nil,
description_lists: false,
footnotes: false,
header_ids: nil,
superscript: false,
tasklist: false,
autolink: false,
table: false,
tagfilter: false,
strikethrough: false
]
]
@spec default_parse_options() :: parse_options()
Returns the default :parse
options.
[
relaxed_autolinks: true,
relaxed_tasklist_matching: false,
default_info_string: nil,
smart: false
]
@spec default_render_options() :: render_options()
Returns the default :render
options.
[
experimental_minimize_commonmark: false,
ol_width: 1,
tasklist_classes: false,
figure_with_caption: false,
prefer_fenced: false,
gfm_quirks: false,
ignore_empty_links: false,
ignore_setext: false,
escaped_char_spans: false,
sourcepos: false,
list_style: :dash,
escape: false,
unsafe: false,
width: 0,
full_info_string: false,
github_pre_lang: false,
hardbreaks: false
]
@spec default_sanitize_options() :: sanitize_options()
Returns the default :sanitize
options.
[
id_prefix: nil,
strip_comments: true,
rm_allowed_classes: %{},
add_allowed_classes: %{},
allowed_classes: %{},
link_rel: "noopener noreferrer",
url_relative: :passthrough,
rm_url_schemes: [],
add_url_schemes: [],
url_schemes: ["bitcoin", "ftp", "ftps", "geo", "http", "https", "im", "irc",
"ircs", "magnet", "mailto", "mms", "mx", "news", "nntp", "openpgp4fpr",
"sip", "sms", "smsto", "ssh", "tel", "url", "webcal", "wtai", "xmpp"],
rm_generic_attributes: [],
add_generic_attributes: [],
generic_attributes: ["lang", "title"],
rm_generic_attribute_prefixes: [],
add_generic_attribute_prefixes: [],
generic_attribute_prefixes: [],
rm_set_tag_attribute_value: %{},
set_tag_attribute_value: %{},
set_tag_attribute_values: %{},
rm_tag_attribute_values: %{},
add_tag_attribute_values: %{},
tag_attribute_values: %{},
rm_tag_attributes: %{},
add_tag_attributes: %{},
tag_attributes: %{
"a" => ["href", "hreflang"],
"bdo" => ["dir"],
"blockquote" => ["cite"],
"code" => ["class", "translate", "tabindex"],
"col" => ["align", "char", "charoff", "span"],
"colgroup" => ["align", "char", "charoff", "span"],
"del" => ["cite", "datetime"],
"hr" => ["align", "size", "width"],
"img" => ["align", "alt", "height", "src", "width"],
"ins" => ["cite", "datetime"],
"ol" => ["start"],
"pre" => ["class", "style"],
"q" => ["cite"],
"span" => ["class", "style", "data-line"],
"table" => ["align", "char", "charoff", "summary"],
"tbody" => ["align", "char", "charoff"],
"td" => ["align", "char", "charoff", "colspan", "headers", "rowspan"],
"tfoot" => ["align", "char", "charoff"],
"th" => ["align", "char", "charoff", "colspan", "headers", "rowspan",
"scope"],
"thead" => ["align", "char", "charoff"],
"tr" => ["align", "char", "charoff"]
},
rm_clean_content_tags: [],
add_clean_content_tags: [],
clean_content_tags: ["script", "style"],
rm_tags: [],
add_tags: [],
tags: ["a", "abbr", "acronym", "area", "article", "aside", "b", "bdi", "bdo",
"blockquote", "br", "caption", "center", "cite", "code", "col", "colgroup",
"data", "dd", "del", "details", "dfn", "div", "dl", "dt", "em", "figcaption",
"figure", "footer", "h1", "h2", "h3", "h4", "h5", "h6", "header", "hgroup",
"hr", "i", "img", "ins", "kbd", "li", "map", "mark", "nav", "ol", "p", "pre",
"q", "rp", "rt", "rtc", "ruby", "s", "samp", "small", "span", "strike",
"strong", "sub", "summary", "sup", "table", "tbody", "td", "th", "thead",
"time", "tr", "tt", "u", "ul", "var", "wbr"]
]
@spec default_syntax_highlight_options() :: syntax_highlight_options()
Returns the default :syntax_highlight
options.
[
formatter: {:html_inline,
[
header: nil,
highlight_lines: nil,
include_highlights: false,
italic: false,
pre_class: nil,
theme: "onedark"
]}
]
Callback implementation for Access.fetch/2
.
See the Access section for more info.
Callback implementation for Access.get_and_update/3
.
See the Access section for more info.
Retrieves an option value from the document.
Examples
iex> document = MDEx.new(render: [escape: true])
iex> MDEx.Document.get_option(document, :render)[:escape]
true
Retrieves a private value from the document.
Examples
iex> document = MDEx.new() |> MDEx.Document.put_private(:count, 2)
iex> MDEx.Document.get_private(document, :count)
2
Retrieves one of the sanitize_options/0
options from the document.
Examples
iex> document =
...> MDEx.new()
...> |> MDEx.Document.put_sanitize_options(add_tags: ["x-component"])
iex> MDEx.Document.get_sanitize_option(document, :add_tags)
["x-component"]
Halts the document pipeline execution.
This function is used to stop the pipeline from processing any further steps. Once a pipeline is halted, no more steps will be executed. This is useful for plugins that need to stop processing when certain conditions are met or when an error occurs.
Examples
iex> document = MDEx.Document.halt(MDEx.new())
iex> document.halted
true
@spec halt(t(), Exception.t()) :: {t(), Exception.t()}
Halts the document pipeline execution with an exception.
Returns true
if the document has the :sanitize
option set, otherwise false
.
Callback implementation for Access.fetch/2
.
See the Access section for more info.
Prepends steps to the beginning of the existing document's step list.
@spec put_extension_options(t(), extension_options()) :: t()
Updates the document's :extension
options.
Examples
iex> document = MDEx.Document.put_extension_options(MDEx.new(), table: true)
iex> MDEx.Document.get_option(document, :extension)[:table]
true
Adds markdown
chunks into the document
buffer.
Examples
iex> document =
...> MDEx.new(markdown: "# First\n")
...> |> MDEx.Document.put_markdown("# Second")
...> |> MDEx.Document.run()
iex> document.nodes
[
%MDEx.Heading{nodes: [%MDEx.Text{literal: "First"}], level: 1, setext: false},
%MDEx.Heading{nodes: [%MDEx.Text{literal: "Second"}], level: 1, setext: false}
]
iex> document =
...> MDEx.new(markdown: "# Last")
...> |> MDEx.Document.put_markdown("# First\n", :top)
...> |> MDEx.Document.run()
iex> document.nodes
[
%MDEx.Heading{nodes: [%MDEx.Text{literal: "First"}], level: 1, setext: false},
%MDEx.Heading{nodes: [%MDEx.Text{literal: "Last"}], level: 1, setext: false}
]
iex> document = MDEx.new(streaming: true) |> MDEx.Document.put_markdown("`let x =")
iex> MDEx.to_html!(document)
"<p><code>let x =</code></p>"
Inserts node
into the document root at the specified position
.
- By default, the node is inserted at the top of the document.
- Node must be a valid fragment node like a
MDEx.Heading
,MDEx.HtmlBlock
, etc.
Examples
iex> document =
...> MDEx.new(markdown: "# Doc")
...> |> MDEx.Document.append_steps(append_node: fn document ->
...> html_block = %MDEx.HtmlBlock{literal: "<p>Hello</p>"}
...> MDEx.Document.put_node_in_document_root(document, html_block, :bottom)
...> end)
iex> MDEx.to_html(document, render: [unsafe: true])
{:ok, "<h1>Doc</h1>\n<p>Hello</p>"}
Merges options into the document options.
This function handles both built-in options (:extension
, :parse
, :render
, :syntax_highlight
, and :sanitize
)
and user-defined options that have been registered with register_options/2
.
Examples
iex> document = MDEx.Document.register_options(MDEx.new(), [:custom_option])
iex> document = MDEx.Document.put_options(document, [
...> extension: [table: true],
...> custom_option: "value"
...> ])
iex> MDEx.Document.get_option(document, :extension)[:table]
true
iex> MDEx.Document.get_option(document, :custom_option)
"value"
Built-in options are validated against their respective schemas:
iex> try do
...> MDEx.Document.put_options(MDEx.new(), [extension: [invalid: true]])
...> rescue
...> NimbleOptions.ValidationError -> :error
...> end
:error
@spec put_parse_options(t(), parse_options()) :: t()
Updates the document's :parse
options.
Examples
iex> document = MDEx.Document.put_parse_options(MDEx.new(), smart: true)
iex> MDEx.Document.get_option(document, :parse)[:smart]
true
Stores a value in the document's private storage.
Examples
iex> document = MDEx.Document.put_private(MDEx.new(), :mermaid_version, "11")
iex> MDEx.Document.get_private(document, :mermaid_version)
"11"
@spec put_render_options(t(), render_options()) :: t()
Updates the document's :render
options.
Examples
iex> document = MDEx.Document.put_render_options(MDEx.new(), escape: true)
iex> MDEx.Document.get_option(document, :render)[:escape]
true
@spec put_sanitize_options(t(), sanitize_options()) :: t()
Updates the document's :sanitize
options.
Examples
iex> document = MDEx.Document.put_sanitize_options(MDEx.new(), add_tags: ["MyComponent"])
iex> MDEx.Document.get_option(document, :sanitize)[:add_tags]
["MyComponent"]
@spec put_syntax_highlight_options(t(), syntax_highlight_options()) :: t()
Updates the document's :syntax_highlight
options.
Examples
iex> document = MDEx.Document.put_syntax_highlight_options(MDEx.new(), formatter: :html_linked)
iex> MDEx.Document.get_option(document, :syntax_highlight)[:formatter]
:html_linked
Registers a list of valid options that can be used by steps in the document pipeline.
Examples
iex> document = MDEx.new()
iex> document = MDEx.Document.register_options(document, [:mermaid_version])
iex> document = MDEx.Document.put_options(document, mermaid_version: "11")
iex> document.options[:mermaid_version]
"11"
iex> MDEx.new(rendr: [unsafe: true])
** (ArgumentError) unknown option :rendr. Did you mean :render?
Executes the document pipeline.
This function performs some main operations:
Processes buffered markdown: If there are any markdown chunks in the buffer (added via
put_markdown/3
for example), they are parsed and added to the document. If the document already has nodes, they are combined with the buffer.Completes any buffered fragments: If streaming is enabled, it completes any buffered fragments to ensure valid Markdown.
Executes pipeline steps: All registered steps (added via
append_steps/2
orprepend_steps/2
) are executed in order. Steps can transform the document or halt the pipeline.
See MDEx.new/1
for more info.
Examples
Processing buffered markdown:
iex> document =
...> MDEx.new(markdown: "# First\n")
...> |> MDEx.Document.put_markdown("# Second")
...> |> MDEx.Document.run()
iex> document.nodes
[
%MDEx.Heading{nodes: [%MDEx.Text{literal: "First"}], level: 1, setext: false},
%MDEx.Heading{nodes: [%MDEx.Text{literal: "Second"}], level: 1, setext: false}
]
Executing pipeline steps:
iex> document =
...> MDEx.new()
...> |> MDEx.Document.append_steps(add_heading: fn doc ->
...> heading = %MDEx.Heading{nodes: [%MDEx.Text{literal: "Intro"}], level: 1, setext: false}
...> MDEx.Document.put_node_in_document_root(doc, heading, :top)
...> end)
...> |> MDEx.Document.run()
iex> document.nodes
[%MDEx.Heading{nodes: [%MDEx.Text{literal: "Intro"}], level: 1, setext: false}]
Streaming:
iex> document =
...> MDEx.new(streaming: true, markdown: "```elixir\n")
...> |> MDEx.Document.put_markdown("IO.inspect(:mdex)")
...> |> MDEx.Document.run()
iex> document.nodes
[
%MDEx.CodeBlock{
info: "elixir",
literal: "IO.inspect(:mdex)\n"
}
]
Updates all nodes in the document that match selector
.
Example
iex> markdown = """
...> # Hello
...> ## World
...> """
iex> document =
...> MDEx.new(markdown: markdown)
...> |> MDEx.Document.run()
...> |> MDEx.Document.update_nodes(MDEx.Text, fn node -> %{node | literal: String.upcase(node.literal)} end)
iex> document.nodes
[
%MDEx.Heading{nodes: [%MDEx.Text{literal: "HELLO"}], level: 1, setext: false},
%MDEx.Heading{nodes: [%MDEx.Text{literal: "WORLD"}], level: 2, setext: false}
]
Updates a value in the document's private storage using a function.
Examples
iex> document = MDEx.new() |> MDEx.Document.put_private(:count, 1)
iex> document = MDEx.Document.update_private(document, :count, 0, &(&1 + 1))
iex> MDEx.Document.get_private(document, :count)
2
Wraps nodes in a MDEx.Document
.
- Passing an existing document returns it unchanged.
- Passing a node or list of nodes builds a new document with default options.
Examples
iex> document = MDEx.Document.wrap(MDEx.new(markdown: "# Title") |> MDEx.Document.run())
iex> document.nodes
[%MDEx.Heading{nodes: [%MDEx.Text{literal: "Title"}], level: 1, setext: false}]
iex> document = MDEx.Document.wrap(%MDEx.Text{literal: "Hello"})
iex> document.nodes
[%MDEx.Text{literal: "Hello"}]