Features
- Fast
- Compliant with the CommonMark spec
- Plugins
- Formats:
- Markdown (CommonMark)
- HTML
- Phoenix HEEx
- JSON
- XML
- Quill Delta
- Slack mrkdwn
- Floki-like Document AST
- Req-like Document pipeline API
- GitHub Flavored Markdown
- GitLab Flavored Markdown
- Discord Flavored Markdown (Partial)
- Wiki-style links
- Phoenix HEEx components and expressions
- Native Elixir Stream for Markdown chunks
- Emoji shortcodes
- Built-in Syntax Highlighting with Lumis or Syntect
- Code Block Decorators
- HTML sanitization
- ~MD Sigil for Markdown, HTML, HEEx, JSON, XML, and Quill Delta
Plugins
- mdex_gfm - Enable GitHub Flavored Markdown (GFM)
- mdex_mermaid - Render Mermaid diagrams in code blocks
- mdex_katex - Render math formulas using KaTeX
- mdex_video_embed - Privacy-respecting video embeds from code blocks
- mdex_custom_heading_id - Custom heading IDs for markdown headings
- mdex_mermex - Render Mermaid diagrams server-side using Mermex (Rust NIF)
- mdex_multiline_cells - Multi-line cells in Markdown tables with inline/block Markdown rendering
Installation
Add :mdex dependency:
def deps do
[
{:mdex, "~> 0.12"}
]
endOr use Igniter:
mix igniter.install mdex
Usage
Convert to HTML
iex> MDEx.to_html!("# Hello :smile:", extension: [shortcodes: true])
"<h1>Hello π</h1>"Syntax Highlighting
Syntax highlight code blocks using either Lumis or Syntect, for example to use Lumis:
def deps do
[
{:mdex, "~> 0.12"},
{:lumis, "~> 0.10"},
# one package per language you highlight, or a `lumis_wasm_bundle_*` package
{:lumis_wasm_elixir, "~> 0.26"}
]
endLumis no longer downloads parsers at runtime, so a language is only highlighted when its package is a dependency. See the Lumis languages reference for the full catalog and the available bundles.
config :mdex_native, syntax_highlighter: :lumisGitHub Flavored Markdown (GFM)
Using the :html_multi_themes syntax highlighting formatter is not required but you get light/dark using it.
Mix.install([
{:mdex_gfm, "~> 0.1"}
])
markdown = """
- [x] Set up project
- [ ] Write docs
```elixir
spawn(fn -> send(current, {self(), 1 + 2}) end)
```
"""
MDEx.new(
markdown: markdown,
syntax_highlight: [
engine: :lumis,
opts: [
formatter: {:html_multi_themes,
themes: [light: "github_light", dark: "github_dark"],
default_theme: "light-dark()"}
]
]
)
|> MDExGFM.attach()
|> MDEx.to_html!()Sigils
iex> import MDEx.Sigil
iex> ~MD[# Hello :smile:]HTML
"<h1>Hello π</h1>"iex> import MDEx.Sigil
iex> assigns = %{project: "MDEx"}
iex> ~MD[# {@project}]HEEX
%Phoenix.LiveView.Rendered{...}iex> import MDEx.Sigil
iex> ~MD[# Hello :smile:]
#MDEx.Document(3 nodes)<
βββ 1 [heading] level: 1, setext: false
β βββ 2 [text] literal: "Hello "
β βββ 3 [short_code] code: "smile", emoji: "π"
>Streaming
iex> ["# Install **MD", "Ex**\n\n`{:mdex,", " \"~> 0.12\"}`\n\n", "Enjoy!"]
...> |> MDEx.stream()
...> |> Enum.map(fn {id, document} -> {id, MDEx.to_html!(document)} end)
[
{0, "<h1>Install <strong>MD</strong></h1>"},
{0, "<h1>Install <strong>MDEx</strong></h1>"},
{1, "<p><code>{:mdex,</code></p>"},
{1, "<p><code>{:mdex, "~> 0.12"}</code></p>"},
{2, "<p>Enjoy!</p>"}
]Examples and Guides
In docs you can find Livebook examples covering options and usage, and Guides for more info.
Foundation
The library is built on top of:
- comrak - a fast Rust port of GitHub's CommonMark parser
- ammonia for HTML Sanitization
- lumis for Syntax Highlighting
Parsing
Converts Markdown to an AST data structure that can be inspected and manipulated to change the content of the document programmatically.
The data structure format is inspired on Floki (with :attributes_as_maps = true) so we can keep similar APIs and keep the same mental model when
working with these documents, either Markdown or HTML, where each node is represented as a struct holding the node name as the struct name and its attributes and children, for eg:
%MDEx.Heading{
level: 1
nodes: [...],
}The parent node that represents the root of the document is the MDEx.Document struct,
where you can find more more information about the AST and what operations are available.
The complete list of nodes is listed in the the section Document Nodes.
Formatting
Formatting is the process of converting from one format to another, for example from AST or Markdown to HTML. Formatting to XML and to Markdown is also supported.
You can use MDEx.parse_document/2 to generate an AST or any of the to_* functions to convert to Markdown (CommonMark), HTML, JSON, or XML.
Summary
Functions
Sets up MDEx in the calling module.
Convert a given text string to a format that can be used as an "anchor", such as in a Table of Contents.
Creates an MDEx.Document.
Parse source and returns MDEx.Document.
Same as parse_document/2 but raises if the parsing fails.
Parse a markdown string and returns only the node that represents the fragment.
Same as parse_fragment/2 but raises if the parsing fails or returns nil.
Utility function to sanitize and escape HTML.
Returns a lazy Stream of parsed Markdown chunks.
Convert Markdown or MDEx.Document to Quill Delta format.
Same as to_delta/2 but raises on error.
Convert Markdown, MDEx.Document, or HTML to HEEx with support for Phoenix components.
Same as to_heex/2 but raises error if the conversion fails.
Convert Markdown or MDEx.Document to HTML.
Same as to_html/2 but raises error if the conversion fails.
Convert Markdown or MDEx.Document to JSON.
Same as to_json/2 but raises an error if the conversion fails.
Convert MDEx.Document to Markdown using default options.
Same as to_markdown/1 but raises MDEx.DecodeError if the conversion fails.
Convert Markdown or MDEx.Document to Slack mrkdwn format.
Same as to_slack/2 but raises on error.
Convert Markdown or MDEx.Document to XML.
Same as to_xml/2 but raises error if the conversion fails.
Low-level function to traverse and update the Markdown document preserving the tree structure format.
Low-level function to traverse and update the Markdown document preserving the tree structure format and keeping an accumulator.
Types
@type plugins() :: [ module() | {module(), keyword()} | (MDEx.Document.t() -> MDEx.Document.t()) ]
A list of plugins to attach to an MDEx.Document with MDEx.new/1.
Each list member may be one of:
module/0- A module that exposesattach/1, where theMDEx.Document.t/0is the only parameter{module, keyword}- A module exposingattach/2, where theMDEx.Document.t/0is the first the parameter, and the second parameter is a keyword option list(document -> document)- A/1function that accepts aMDEx.Document.t/0
@type source() :: markdown :: String.t() | MDEx.Document.t()
Input source document.
Examples
From Markdown to HTML
iex> MDEx.to_html!("# Hello") "<h1>Hello</h1>"From Markdown to
MDEx.Documentiex> MDEx.parse_document!("Hello") %MDEx.Document{ nodes: [ %MDEx.Paragraph{nodes: [%MDEx.Text{literal: "Hello"}]} ] }From
MDEx.Documentto HTMLiex> MDEx.to_html!(%MDEx.Document{ ...> nodes: [ ...> %MDEx.Paragraph{nodes: [%MDEx.Text{literal: "Hello"}]} ...> ] ...> }) "<p>Hello</p>"
You can also leverage MDEx.Document as an intermediate data type to convert between formats:
From JSON to HTML:
iex> json = ~s|{"nodes":[{"nodes":[{"literal":"Hello","node_type":"MDEx.Text"}],"level":1,"setext":false,"node_type":"MDEx.Heading"}],"node_type":"MDEx.Document"}| iex> {:json, json} |> MDEx.parse_document!() |> MDEx.to_html!() "<h1>Hello</h1>"
Functions
Sets up MDEx in the calling module.
This macro:
require MDEx- enables theto_heex/2macro (requires Phoenix LiveView)import MDEx.Sigil- enables the~MDsigil
Options
You can pass MDEx.Document.options/0 to customize the ~MD sigil behavior.
These options are merged with the sigil's default options (see MDEx.Sigil for defaults).
defmodule MyApp.CustomMarkdown do
use MDEx,
extension: [strikethrough: false],
syntax_highlight: [engine: :lumis, opts: [formatter: {:html_inline, theme: "catppuccin_latte"}]],
plugins: [MyApp.MarkdownPlugin]
def render do
~MD|Hello ~world~|HTML
end
endHEEX modifier requirements
The HEEX modifier enforces extension: [phoenix_heex: true] and render: [unsafe: true]
regardless of custom options.
Examples
Using the ~MD sigil in a LiveView:
defmodule MyApp.PageLive do
use Phoenix.LiveView
use MDEx
def render(assigns) do
~MD"""
# FAQ
<%= for {title, href} <- @toc do %>
## <.link href={href}>{title}</.link>
<% end %>
"""HEEX
end
endGenerating static HTML on environments where ~MD sigil is not available:
defmodule MyApp.StaticHtmlBlog do
use MDEx
import Phoenix.Component
def render(assigns) do
MDEx.to_heex!(~s[<.link href={@url}>Click here</.link>], assigns: assigns)
|> MDEx.to_html!()
end
end
Convert a given text string to a format that can be used as an "anchor", such as in a Table of Contents.
This uses the same algorithm GFM uses for anchor ids, so it can be used reliably.
Repeated anchors
GFM will dedupe multiple repeated anchors with the same value by appending an incrementing number to the end of the anchor. That is beyond the scope of this function, so you will have to handle it yourself.
Examples
iex> MDEx.anchorize("Hello World")
"hello-world"
iex> MDEx.anchorize("Hello, World!")
"hello-world"
iex> MDEx.anchorize("Hello -- World")
"hello----world"
iex> MDEx.anchorize("Hello World 123")
"hello-world-123"
iex> MDEx.anchorize("δ½ ε₯½δΈη")
"δ½ ε₯½δΈη"
@spec new(keyword()) :: MDEx.Document.t()
Creates an MDEx.Document.
A document stores the CommonMark AST, options, assigns, and pipeline steps.
MDEx.Document exposes the tree through Elixir's Enumerable, Collectable,
and Access APIs. You can inspect and traverse the AST, find nodes by index,
type, structure, or predicate, update or remove matching nodes, insert new
nodes, merge documents, and run transformations before rendering.
- Pass
:markdownto add source when the document is created. - Call
MDEx.Document.put_markdown/3to add more source later. - Pass MDEx options to control parsing and rendering.
- Add pipeline steps or plugins to change the AST.
- Use
MDEx.stream/2instead when the source is an Enumerable of chunks.
Call an MDEx.to_* function to render the document. Call
MDEx.Document.run/1 when you need the parsed AST.
Options
:markdown(String.t/0) - Markdown source. Defaults to"".:plugins(plugins/0) - Plugins for the document pipeline. Defaults to[].:extension(MDEx.Document.extension_options/0) - Markdown extensions.:parse(MDEx.Document.parse_options/0) - Parser options.:render(MDEx.Document.render_options/0) - Render options.:syntax_highlight(MDEx.Document.syntax_highlight_options/0|nil) - Syntax highlight options, ornilto turn it off.:sanitize(t:sanitize_options/0|nil) - HTML cleaning options, ornilto turn it off. Defaults tonil.:assigns(map/0|keyword/0) - Values for pipelines, plugins, and HEEx. Defaults to%{}.:auto_close(boolean/0) - Closes Markdown syntax left open at the end of the source. Defaults tofalse, and totrueinMDEx.stream/2.
The :streaming option is deprecated. Use :auto_close.
:sanitize and :unsafe are off by default. See the
Safety guide.
Examples
iex> MDEx.new(markdown: "# Hello") |> MDEx.to_html!()
"<h1>Hello</h1>"
iex> MDEx.new(markdown: "Hello ~world~", extension: [strikethrough: true]) |> MDEx.to_html!()
"<p>Hello <del>world</del></p>"
iex> MDEx.new(markdown: "# Intro")
...> |> MDEx.Document.append_steps(inject_html: fn doc ->
...> snippet = %MDEx.HtmlBlock{literal: "<section>Injected</section>"}
...> MDEx.Document.put_node_in_document_root(doc, snippet, :bottom)
...> end)
...> |> MDEx.to_html!(render: [unsafe: true])
"<h1>Intro</h1>\n<section>Injected</section>"Parse buffered Markdown and return the AST with MDEx.Document.run/1:
iex> doc = MDEx.new(markdown: "# First\n")
...> |> MDEx.Document.put_markdown("# Second")
...> |> MDEx.Document.run()
iex> doc.nodes
[
%MDEx.Heading{nodes: [%MDEx.Text{literal: "First", sourcepos: %MDEx.Sourcepos{start: {1, 3}, end: {1, 7}}}], level: 1, setext: false, closed: false, sourcepos: %MDEx.Sourcepos{start: {1, 1}, end: {1, 7}}},
%MDEx.Heading{nodes: [%MDEx.Text{literal: "Second", sourcepos: %MDEx.Sourcepos{start: {2, 3}, end: {2, 8}}}], level: 1, setext: false, closed: false, sourcepos: %MDEx.Sourcepos{start: {2, 1}, end: {2, 8}}}
]Attach plugins three different ways:
plugins = [
MDExGFM,
{MDExKatex,
block_attrs: fn seq ->
~s(id="katex-) <> to_string(seq) <> ~s(" class="katex-block" phx-update="ignore")
end},
fn doc -> MDExMermaid.attach(doc) end
]
MDEx.new(plugins: plugins)
@spec parse_document( markdown :: String.t() | {:json, String.t()}, MDEx.Document.options() ) :: {:ok, MDEx.Document.t()} | {:error, any()}
Parse source and returns MDEx.Document.
Source can be either a Markdown string or a tagged JSON string.
This function is essentially a shortcut for MDEx.new(markdown: source) |> MDEx.Document.run()
Examples
Parse Markdown with default options:
iex> MDEx.parse_document!(""" ...> # Languages ...> ...> - Elixir ...> - Rust ...> """) %MDEx.Document{ nodes: [ %MDEx.Heading{nodes: [%MDEx.Text{literal: "Languages"}], level: 1, setext: false}, %MDEx.List{ nodes: [ %MDEx.ListItem{ nodes: [%MDEx.Paragraph{nodes: [%MDEx.Text{literal: "Elixir"}]}], list_type: :bullet, marker_offset: 0, padding: 2, start: 1, delimiter: :period, bullet_char: "-", tight: false }, %MDEx.ListItem{ nodes: [%MDEx.Paragraph{nodes: [%MDEx.Text{literal: "Rust"}]}], list_type: :bullet, marker_offset: 0, padding: 2, start: 1, delimiter: :period, bullet_char: "-", tight: false } ], list_type: :bullet, marker_offset: 0, padding: 2, start: 1, delimiter: :period, bullet_char: "-", tight: true } ] }Parse Markdown with custom options:
iex> MDEx.parse_document!("Darth Vader is ||Luke's father||", extension: [spoiler: true]) %MDEx.Document{ nodes: [ %MDEx.Paragraph{ nodes: [ %MDEx.Text{literal: "Darth Vader is "}, %MDEx.SpoileredText{nodes: [%MDEx.Text{literal: "Luke's father"}]} ] } ] }Parse JSON:
iex> json = ~s|{"nodes":[{"nodes":[{"literal":"Title","node_type":"MDEx.Text"}],"level":1,"setext":false,"node_type":"MDEx.Heading"}],"node_type":"MDEx.Document"}| iex> MDEx.parse_document!({:json, json}) %MDEx.Document{ nodes: [ %MDEx.Heading{ nodes: [%MDEx.Text{literal: "Title"} ], level: 1, setext: false } ] }
@spec parse_document!( markdown :: String.t() | {:json, String.t()}, MDEx.Document.options() ) :: MDEx.Document.t()
Same as parse_document/2 but raises if the parsing fails.
@spec parse_fragment(String.t(), MDEx.Document.options()) :: {:ok, MDEx.Document.md_node()} | nil
Parse a markdown string and returns only the node that represents the fragment.
Usually that means filtering out the parent document and paragraphs.
That's useful to generate fragment nodes and inject them into the document when you're manipulating it.
Use parse_document/2 to generate a complete document.
Experimental
Consider this function experimental and subject to change.
Examples
iex> MDEx.parse_fragment("# Elixir")
{:ok, %MDEx.Heading{nodes: [%MDEx.Text{literal: "Elixir", sourcepos: %MDEx.Sourcepos{start: {1, 3}, end: {1, 8}}}], level: 1, setext: false, closed: false, sourcepos: %MDEx.Sourcepos{start: {1, 1}, end: {1, 8}}}}
iex> MDEx.parse_fragment("<h1>Elixir</h1>")
{:ok, %MDEx.HtmlBlock{nodes: [], block_type: 6, literal: "<h1>Elixir</h1>", sourcepos: %MDEx.Sourcepos{start: {1, 1}, end: {1, 15}}}}
@spec parse_fragment!(String.t(), MDEx.Document.options()) :: MDEx.Document.md_node()
Same as parse_fragment/2 but raises if the parsing fails or returns nil.
Experimental
Consider this function experimental and subject to change.
@spec safe_html( String.t(), options :: [ sanitize: MDEx.Document.sanitize_options() | nil | false, escape: [atom()] ] ) :: String.t()
Utility function to sanitize and escape HTML.
Examples
iex> MDEx.safe_html("<script>console.log('attack')</script>")
""
iex> MDEx.safe_html("<custom_tag>Hello</custom_tag>")
"Hello"
iex> MDEx.safe_html("<custom_tag>Hello</custom_tag>", sanitize: [add_tags: ["custom_tag"]], escape: [content: false])
"<custom_tag>Hello</custom_tag>"
iex> MDEx.safe_html("<script>console.log('attack')</script>", sanitize: false, escape: [content: false])
"<script>console.log('attack')</script>"
iex> MDEx.safe_html("<h1>{'Example:'}</h1><code>{:ok, 'MDEx'}</code>")
"<h1>{'Example:'}</h1><code>{:ok, 'MDEx'}</code>"
iex> MDEx.safe_html("<h1>{'Example:'}</h1><code>{:ok, 'MDEx'}</code>", escape: [content: false])
"<h1>{'Example:'}</h1><code>{:ok, 'MDEx'}</code>"Options
:sanitize- cleans HTML after rendering. Defaults toMDEx.Document.default_sanitize_options()/0when omitted ornil.keyword-t:sanitize_options/0false- do not sanitize output.
:escape- which entities should be escaped. Defaults to[:content, :curly_braces_in_code].:content- escape common chars like<,>,&, and others in the HTML content;:curly_braces_in_code- escape{and}only inside<code>tags, particularly useful for compiling HTML in LiveView;
@spec stream(Enumerable.t(), MDEx.Document.options()) :: Enumerable.t()
Returns a lazy Stream of parsed Markdown chunks.
The input must be an Enumerable of binaries. The output is a native Elixir
Stream of {id, %MDEx.Document{}} pairs.
Insert a chunk when its id is new. Replace it when the id repeats. A later source chunk may update any earlier id when document-wide Markdown changes its AST, such as a link reference definition. An id always keeps its original position. At EOF, MDEx parses the open chunk normally and emits it only when its AST changed after removing temporary fragment completion.
A file or network read may split a UTF-8 code point. MDEx holds the incomplete bytes until the next chunk.
Each document contains a parsed AST. It can be changed before rendering:
markdown_chunks
|> MDEx.stream()
|> Stream.map(fn {id, document} ->
document =
MDEx.Document.update_nodes(document, MDEx.Link, fn link ->
%{link | url: rewrite_url(link.url)}
end)
{id, MDEx.to_html!(document)}
end)The transform sees one keyed document, not the full Markdown response. A transform that needs all content must wait for the full source or keep its own state across chunks and repeated ids.
Plugins are attached once when the Stream starts. Parser options configured by a plugin apply to every parse. The plugin pipeline runs on every emitted document, including replacements with the same id. Plugins must not expect an emitted document or its buffer to contain the full Markdown response.
Examples
iex> ["# Hel", "lo\n\nNow **wri", "ting**"]
...> |> MDEx.stream()
...> |> Enum.map(fn {id, document} -> {id, MDEx.to_html!(document)} end)
[
{0, "<h1>Hel</h1>"},
{0, "<h1>Hello</h1>"},
{1, "<p>Now <strong>wri</strong></p>"},
{1, "<p>Now <strong>writing</strong></p>"}
]File.stream!/1 and other Enumerables work without an adapter:
"README.md"
|> File.stream!([], 2048)
|> MDEx.stream(extension: [table: true])
|> Enum.each(fn {id, document} ->
cache({id, :html}, MDEx.to_html!(document))
end)Markdown syntax left open at the end of a chunk is closed so partial output
stays readable, which is why the first document above renders <h1>Hel</h1>
rather than # Hel. Pass auto_close: false to render the source as written:
iex> ["a [x](htt"] |> MDEx.stream(auto_close: false) |> Enum.map(fn {id, doc} -> {id, MDEx.to_html!(doc)} end)
[{0, "<p>a [x](htt</p>"}]See the Streaming guide for the id rules, Req, and Phoenix LiveView.
Experimental
Consider this function experimental and subject to change.
@spec to_delta(source(), keyword()) :: {:ok, [map()]} | {:error, MDEx.DecodeError.t()} | {:error, MDEx.InvalidInputError.t()}
Convert Markdown or MDEx.Document to Quill Delta format.
Quill Delta is a JSON-based format that represents documents as a sequence of insert, retain, and delete operations. This format is commonly used by the Quill rich text editor.
Examples
iex> MDEx.to_delta("# Hello\n**World**")
{:ok, [
%{"insert" => "Hello"},
%{"insert" => "\n", "attributes" => %{"header" => 1}},
%{"insert" => "World", "attributes" => %{"bold" => true}},
%{"insert" => "\n"}
]}
iex> doc = MDEx.parse_document!("*italic* text")
iex> MDEx.to_delta(doc)
{:ok, [
%{"insert" => "italic", "attributes" => %{"italic" => true}},
%{"insert" => " text"},
%{"insert" => "\n"}
]}Node Type Mappings
The following table shows how MDEx node types are converted to Delta attributes:
| MDEx Node Type | Delta Attribute | Example |
|---|---|---|
MDEx.Strong | {"bold": true} | **text** β {"insert": "text", "attributes": {"bold": true}} |
MDEx.Emph | {"italic": true} | *text* β {"insert": "text", "attributes": {"italic": true}} |
MDEx.Code | {"code": true} | `code` β {"insert": "code", "attributes": {"code": true}} |
MDEx.Strikethrough | {"strike": true} | ~~text~~ β {"insert": "text", "attributes": {"strike": true}} |
MDEx.Underline | {"underline": true} | __text__ β {"insert": "text", "attributes": {"underline": true}} |
MDEx.Subscript | {"subscript": true} | H~2~O β {"insert": "2", "attributes": {"subscript": true}} |
MDEx.Superscript | {"superscript": true} | E=mc^2^ β {"insert": "2", "attributes": {"superscript": true}} |
MDEx.SpoileredText | {"spoiler": true} | ||spoiler|| β {"insert": "spoiler", "attributes": {"spoiler": true}} |
MDEx.Link | {"link": "url"} | [text](url) β {"insert": "text", "attributes": {"link": "url"}} |
MDEx.WikiLink | {"link": "url", "wikilink": true} | [[WikiPage]] β {"insert": "WikiPage", "attributes": {"link": "WikiPage", "wikilink": true}} |
MDEx.Math | {"math": "inline"|"display"} | $x^2$ β {"insert": "x^2", "attributes": {"math": "inline"}} |
MDEx.FootnoteReference | {"footnote_ref": "id"} | [^1] β {"insert": "[^1]", "attributes": {"footnote_ref": "1"}} |
MDEx.HtmlInline | {"html": "inline"} | <span>text</span> β {"insert": "<span>text</span>", "attributes": {"html": "inline"}} |
MDEx.Heading | {"header": level} | # Title β {"insert": "Title"}, {"insert": "\n", "attributes": {"header": 1}} |
MDEx.BlockQuote | {"blockquote": true} | > quote β {"insert": "\n", "attributes": {"blockquote": true}} |
MDEx.CodeBlock | {"code-block": true, "code-block-lang": "lang"} | ```js\ncode``` β {"insert": "\n", "attributes": {"code-block": true, "code-block-lang": "js"}} |
MDEx.ThematicBreak | Text insertion | --- β {"insert": "***\n"} |
MDEx.List (bullet) | {"list": "bullet"} | - item β {"insert": "\n", "attributes": {"list": "bullet"}} |
MDEx.List (ordered) | {"list": "ordered"} | 1. item β {"insert": "\n", "attributes": {"list": "ordered"}} |
MDEx.TaskItem | {"list": "bullet", "task": true/false} | - [x] done β {"insert": "\n", "attributes": {"list": "bullet", "task": true}} |
MDEx.Table | {"table": "header/row"} | Table rows β {"insert": "\n", "attributes": {"table": "header"}} |
MDEx.Alert | {"alert": "type", "alert_title": "title"} | > [!NOTE]\n> text β {"insert": "\n", "attributes": {"alert": "note"}} |
MDEx.FootnoteDefinition | {"footnote_definition": "id"} | [^1]: def β {"insert": "\n", "attributes": {"footnote_definition": "1"}} |
MDEx.HtmlBlock | {"html": "block"} | <div>block</div> β {"insert": "\n", "attributes": {"html": "block"}} |
MDEx.FrontMatter | {"front_matter": true} | ---\ntitle: x\n--- β {"insert": "\n", "attributes": {"front_matter": true}} |
Note: Block-level attributes are applied to newline characters (\n) following Quill Delta conventions.
Inline attributes are applied directly to text content. Multiple attributes can be combined (e.g., bold + italic).
Dangerous link, wikilink, and image URLs are rendered as empty strings by default,
pass render: [unsafe: true] only for trusted input if you need to preserve those URLs.
Options
MDEx.Document.options/0- options passed to the parser and document processing:custom_converters- map of node types to converter functions for custom behavior
Custom Converters
Custom converters allow you to override the default behavior for any node type:
# Example: Custom table converter that creates structured Delta objects
table_converter = fn %MDEx.Table{nodes: rows}, _options ->
[%{
"insert" => %{
"table" => %{
"rows" => length(rows),
"data" => "custom_table_data"
}
}
}]
end
# Example: Skip math nodes entirely
math_skipper = fn %MDEx.Math{}, _options -> :skip end
# Example: Convert images to custom format
image_converter = fn %MDEx.Image{url: url, title: title}, _options ->
[%{
"insert" => %{"custom_image" => %{"src" => url, "alt" => title || ""}},
"attributes" => %{"display" => "block"}
}]
end
# Usage
MDEx.to_delta(document, [
custom_converters: %{
MDEx.Table => table_converter,
MDEx.Math => math_skipper,
MDEx.Image => image_converter
}
])Custom Converter Contract
Input: (node :: MDEx.Document.md_node(), options :: keyword())
Output:
[delta_op()]- List of Delta operations to insert:skip- Skip this node entirely{:error, reason}- Return an error
Note: If you need default conversion behavior for child nodes, call MDEx.to_delta/2 on them.
Same as to_delta/2 but raises on error.
@spec to_heex(source(), MDEx.Document.options()) :: struct()
Convert Markdown, MDEx.Document, or HTML to HEEx with support for Phoenix components.
Returns a Phoenix.LiveView.Rendered struct that can be used in LiveView templates,
or converted to HTML string using MDEx.to_html/1.
Requires use MDEx or require MDEx
This macro requires the module to be required before use.
You can include use MDEx at the top of your module to enable it or add require MDEx.
Performance
Calling to_heex/2 multiple times during runtime might be slow because the template
must be evaluated every time. Prefer moving the operation to compile-time or use MDEx.Sigil.sigil_MD/2.
Options
:assigns- a map or keyword list of assigns to pass to the HEEx template. Defaults to%{}.
Note that the following options are automatically enabled: extension: [phoenix_heex: true] and render: [unsafe: true]
in order to let the parser recognize all tags properly.
Examples
use MDEx
import Phoenix.Component
iex> MDEx.to_heex(~s[<.link href="https://elixir-lang.org">Elixir</.link>])
#=> {:ok, %Phoenix.LiveView.Rendered{...}}
iex> MDEx.to_heex(~s[<.link href="https://elixir-lang.org">Elixir</.link>]) |> MDEx.to_html!()
#=> {:ok, "<a href=\"https://elixir-lang.org\">Elixir</a>"}
iex> assigns = %{url: "https://elixir-lang.org"}
iex> MDEx.to_heex(~s[<.link href={@url}>Elixir</.link>], assigns: assigns) |> MDEx.to_html!()
#=> {:ok, "<a href=\"https://elixir-lang.org\">Elixir</a>"}Using MDEx.Document.assign/3 to set assigns on a document:
iex> MDEx.new(markdown: ~s[<.link href={@url}>{@title}</.link>])
...> |> MDEx.Document.assign(:url, "https://elixir-lang.org")
...> |> MDEx.Document.assign(:title, "Elixir")
...> |> MDEx.to_heex!()
...> |> MDEx.to_html!()
#=> "<a href=\"https://elixir-lang.org\">Elixir</a>"
@spec to_heex!(source(), MDEx.Document.options()) :: struct()
Same as to_heex/2 but raises error if the conversion fails.
@spec to_html(source(), MDEx.Document.options()) :: {:ok, String.t()} | {:error, MDEx.DecodeError.t()} | {:error, MDEx.InvalidInputError.t()}
Convert Markdown or MDEx.Document to HTML.
Phoenix Components Not Supported in to_html
This function does not support Phoenix components like <.link> or custom components.
If you need to use Phoenix components in your Markdown or HTML content, use either MDEx.Sigil.sigil_MD/2 or to_heex/2 instead.
Examples
iex> MDEx.to_html("# MDEx")
{:ok, "<h1>MDEx</h1>"}
iex> MDEx.to_html("Implemented with:\n1. Elixir\n2. Rust")
{:ok, "<p>Implemented with:</p>\n<ol>\n<li>Elixir</li>\n<li>Rust</li>\n</ol>"}
iex> MDEx.to_html(%MDEx.Document{nodes: [%MDEx.Heading{nodes: [%MDEx.Text{literal: "MDEx"}], level: 3, setext: false}]})
{:ok, "<h3>MDEx</h3>"}
iex> MDEx.to_html("Hello ~world~ there", extension: [strikethrough: true])
{:ok, "<p>Hello <del>world</del> there</p>"}
iex> MDEx.to_html("<marquee>visit https://beaconcms.org</marquee>", extension: [autolink: true], render: [unsafe: true])
{:ok, "<p><marquee>visit <a href=\"https://beaconcms.org\">https://beaconcms.org</a></marquee></p>"}Using plugins for one-off conversions:
MDEx.to_html("# Hello", plugins: [MDExGFM])
MDEx.to_html("| a | b |\n|---|---|", plugins: [{MyTablePlugin, style: :compact}])Fragments of a document are also supported:
iex> MDEx.to_html(%MDEx.Paragraph{nodes: [%MDEx.Text{literal: "MDEx"}]})
{:ok, "<p>MDEx</p>"}
@spec to_html!(source(), MDEx.Document.options()) :: String.t()
Same as to_html/2 but raises error if the conversion fails.
@spec to_json(source(), MDEx.Document.options()) :: {:ok, String.t()} | {:error, MDEx.DecodeError.t()} | {:error, MDEx.InvalidInputError.t()} | {:error, Jason.EncodeError.t()} | {:error, Exception.t()}
Convert Markdown or MDEx.Document to JSON.
Examples
iex> MDEx.to_json("# Hello")
{:ok, ~s|{"nodes":[{"nodes":[{"literal":"Hello","node_type":"MDEx.Text"}],"level":1,"setext":false,"node_type":"MDEx.Heading"}],"node_type":"MDEx.Document"}|}
iex> MDEx.to_json("1. First\n2. Second")
{:ok, ~s|{"nodes":[{"start":1,"nodes":[{"start":1,"nodes":[{"nodes":[{"literal":"First","node_type":"MDEx.Text"}],"node_type":"MDEx.Paragraph"}],"delimiter":"period","padding":3,"list_type":"ordered","marker_offset":0,"bullet_char":"","tight":false,"is_task_list":false,"node_type":"MDEx.ListItem"},{"start":2,"nodes":[{"nodes":[{"literal":"Second","node_type":"MDEx.Text"}],"node_type":"MDEx.Paragraph"}],"delimiter":"period","padding":3,"list_type":"ordered","marker_offset":0,"bullet_char":"","tight":false,"is_task_list":false,"node_type":"MDEx.ListItem"}],"delimiter":"period","padding":3,"list_type":"ordered","marker_offset":0,"bullet_char":"","tight":true,"is_task_list":false,"node_type":"MDEx.List"}],"node_type":"MDEx.Document"}|}
iex> MDEx.to_json(%MDEx.Document{nodes: [%MDEx.Heading{nodes: [%MDEx.Text{literal: "Hello"}], level: 3, setext: false}]})
{:ok, ~s|{"nodes":[{"nodes":[{"literal":"Hello","node_type":"MDEx.Text"}],"level":3,"setext":false,"node_type":"MDEx.Heading"}],"node_type":"MDEx.Document"}|}
iex> MDEx.to_json("Hello ~world~", extension: [strikethrough: true])
{:ok, ~s|{"nodes":[{"nodes":[{"literal":"Hello ","node_type":"MDEx.Text"},{"nodes":[{"literal":"world","node_type":"MDEx.Text"}],"node_type":"MDEx.Strikethrough"}],"node_type":"MDEx.Paragraph"}],"node_type":"MDEx.Document"}|}Fragments of a document are also supported:
iex> MDEx.to_json(%MDEx.Paragraph{nodes: [%MDEx.Text{literal: "Hello"}]})
{:ok, ~s|{"nodes":[{"nodes":[{"literal":"Hello","node_type":"MDEx.Text"}],"node_type":"MDEx.Paragraph"}],"node_type":"MDEx.Document"}|}
@spec to_json!(source(), MDEx.Document.options()) :: String.t()
Same as to_json/2 but raises an error if the conversion fails.
@spec to_markdown(MDEx.Document.t(), MDEx.Document.options()) :: {:ok, String.t()} | {:error, MDEx.DecodeError.t()}
Convert MDEx.Document to Markdown using default options.
Example
iex> MDEx.to_markdown(%MDEx.Document{nodes: [%MDEx.Heading{nodes: [%MDEx.Text{literal: "Hello"}], level: 3, setext: false}]})
{:ok, "### Hello"}
@spec to_markdown!(MDEx.Document.t(), MDEx.Document.options()) :: String.t()
Same as to_markdown/1 but raises MDEx.DecodeError if the conversion fails.
@spec to_slack(source(), keyword()) :: {:ok, String.t()} | {:error, MDEx.DecodeError.t()} | {:error, MDEx.InvalidInputError.t()}
Convert Markdown or MDEx.Document to Slack mrkdwn format.
Slack uses its own Markdown dialect named mrkdwn that differs from CommonMark described at https://docs.slack.dev/messaging/formatting-message-text
Dangerous link and image URLs are omitted by default. Pass
render: [unsafe: true] only for trusted input if you need to preserve them.
Examples
iex> MDEx.to_slack("**Hello** _world_")
{:ok, "*Hello* _world_\n"}
iex> MDEx.to_slack("# Title\n[link](https://example.com)")
{:ok, "*Title*\n<https://example.com|link>\n"}Options
MDEx.Document.options/0- options passed to the parser and document processing
Same as to_slack/2 but raises on error.
@spec to_xml(source(), MDEx.Document.options()) :: {:ok, String.t()} | {:error, MDEx.DecodeError.t()} | {:error, MDEx.InvalidInputError.t()}
Convert Markdown or MDEx.Document to XML.
Examples
iex> {:ok, xml} = MDEx.to_xml("Hello ~world~ there", extension: [strikethrough: true])
iex> xml
~s|<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE document SYSTEM "CommonMark.dtd">
<document xmlns="http://commonmark.org/xml/1.0">
<paragraph>
<text xml:space="preserve">Hello </text>
<strikethrough>
<text xml:space="preserve">world</text>
</strikethrough>
<text xml:space="preserve"> there</text>
</paragraph>
</document>|
iex> {:ok, xml} = MDEx.to_xml("<marquee>visit https://beaconcms.org</marquee>", extension: [autolink: true], render: [unsafe: true])
iex> xml
~s|<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE document SYSTEM "CommonMark.dtd">
<document xmlns="http://commonmark.org/xml/1.0">
<paragraph>
<html_inline xml:space="preserve"><marquee></html_inline>
<text xml:space="preserve">visit </text>
<link destination="https://beaconcms.org" title="">
<text xml:space="preserve">https://beaconcms.org</text>
</link>
<html_inline xml:space="preserve"></marquee></html_inline>
</paragraph>
</document>|Fragments of a document are also supported:
iex> {:ok, xml} = MDEx.to_xml(%MDEx.Paragraph{nodes: [%MDEx.Text{literal: "MDEx"}]})
iex> xml
~s|<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE document SYSTEM "CommonMark.dtd">
<document xmlns="http://commonmark.org/xml/1.0">
<paragraph>
<text xml:space="preserve">MDEx</text>
</paragraph>
</document>|
@spec to_xml!(source(), MDEx.Document.options()) :: String.t()
Same as to_xml/2 but raises error if the conversion fails.
@spec traverse_and_update(MDEx.Document.t(), (MDEx.Document.md_node() -> MDEx.Document.md_node())) :: MDEx.Document.t()
Low-level function to traverse and update the Markdown document preserving the tree structure format.
See MDEx.Document for more information about the tree structure and for higher-level functions
using the Access and Enumerable protocols.
Examples
Traverse an entire Markdown document:
iex> import MDEx.Sigil
iex> doc = ~MD"""
...> # Languages
...>
...> `elixir`
...>
...> `rust`
...> """
iex> MDEx.traverse_and_update(doc, fn
...> %MDEx.Code{literal: "elixir"} = node -> %{node | literal: "ex"}
...> %MDEx.Code{literal: "rust"} = node -> %{node | literal: "rs"}
...> node -> node
...> end)
%MDEx.Document{
nodes: [
%MDEx.Heading{nodes: [%MDEx.Text{literal: "Languages"}], level: 1, setext: false},
%MDEx.Paragraph{nodes: [%MDEx.Code{num_backticks: 1, literal: "ex"}]},
%MDEx.Paragraph{nodes: [%MDEx.Code{num_backticks: 1, literal: "rs"}]}
]
}Or fragments of a document:
iex> fragment = MDEx.parse_fragment!("Lang: `elixir`")
iex> MDEx.traverse_and_update(fragment, fn
...> %MDEx.Code{literal: "elixir"} = node -> %{node | literal: "ex"}
...> node -> node
...> end)
%MDEx.Paragraph{nodes: [%MDEx.Text{literal: "Lang: "}, %MDEx.Code{num_backticks: 1, literal: "ex"}]}
@spec traverse_and_update(MDEx.Document.t(), any(), (MDEx.Document.md_node() -> MDEx.Document.md_node())) :: MDEx.Document.t()
Low-level function to traverse and update the Markdown document preserving the tree structure format and keeping an accumulator.
See MDEx.Document for more information about the tree structure and for higher-level functions
using the Access and Enumerable protocols.
Example
iex> import MDEx.Sigil
iex> doc = ~MD"""
...> # Languages
...>
...> `elixir`
...>
...> `rust`
...> """
iex> MDEx.traverse_and_update(doc, 0, fn
...> %MDEx.Code{literal: "elixir"} = node, acc -> {%{node | literal: "ex"}, acc + 1}
...> %MDEx.Code{literal: "rust"} = node, acc -> {%{node | literal: "rs"}, acc + 1}
...> node, acc -> {node, acc}
...> end)
{%MDEx.Document{
nodes: [
%MDEx.Heading{nodes: [%MDEx.Text{literal: "Languages"}], level: 1, setext: false},
%MDEx.Paragraph{nodes: [%MDEx.Code{num_backticks: 1, literal: "ex"}]},
%MDEx.Paragraph{nodes: [%MDEx.Code{num_backticks: 1, literal: "rs"}]}
]
}, 2}Also works with fragments.