defmodule MDEx do
@external_resource "README.md"
@inner_moduledoc """
## 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](https://github.com/philss/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.
"""
@moduledoc "README.md"
|> File.read!()
|> String.split("")
|> Enum.fetch!(1)
|> Kernel.<>(@inner_moduledoc)
alias MDEx.Native
alias MDEx.Document
alias MDEx.DecodeError
alias MDEx.InvalidInputError
import MDEx.Document, only: [is_fragment: 1]
require Logger
@doc """
Sets up MDEx in the calling module.
This macro:
* `require MDEx` - enables the `to_heex/2` macro (requires Phoenix LiveView)
* `import MDEx.Sigil` - enables the `~MD` sigil
## Example
Using the `~MD` sigil in a LiveView:
```elixir
defmodule MyApp.PageLive do
use Phoenix.LiveView
use MDEx
def render(assigns) do
~MD\"\"\"
# FAQ
<%= for {title, href} <- @toc do %>
## <.link href={href}>{title}
<% end %>
\"\"\"HEEX
end
end
```
Generating 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], assigns: assigns)
|> MDEx.to_html!()
end
end
"""
defmacro __using__(_opts) do
quote do
require MDEx
import MDEx.Sigil
end
end
@typedoc """
Input source document.
## Examples
* From Markdown to HTML
iex> MDEx.to_html!("# Hello")
"
Hello
"
* From Markdown to `MDEx.Document`
iex> MDEx.parse_document!("Hello")
%MDEx.Document{
nodes: [
%MDEx.Paragraph{nodes: [%MDEx.Text{literal: "Hello"}]}
]
}
* From `MDEx.Document` to HTML
iex> MDEx.to_html!(%MDEx.Document{
...> nodes: [
...> %MDEx.Paragraph{nodes: [%MDEx.Text{literal: "Hello"}]}
...> ]
...> })
"
Hello
"
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!()
"
Hello
"
"""
@type source :: markdown :: String.t() | Document.t()
@deprecated "Use `MDEx.Document.default_extension_options/0` instead"
def default_extension_options, do: Document.default_extension_options()
@deprecated "Use `MDEx.Document.default_parse_options/0` instead"
def default_parse_options, do: Document.default_parse_options()
@deprecated "Use `MDEx.Document.default_render_options/0` instead"
def default_render_options, do: Document.default_render_options()
@deprecated "Use `MDEx.Document.default_syntax_highlight_options/0` instead"
def default_syntax_highlight_options, do: Document.default_syntax_highlight_options()
@deprecated "Use `MDEx.Document.default_sanitize_options/0` instead"
def default_sanitize_options, do: Document.default_sanitize_options()
@doc """
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()) :: {:ok, Document.t()} | {:error, any()}
# TODO: support :xml :json :delta
def parse_document(source, options \\ [])
def parse_document(source, options) when is_binary(source) and is_list(options) do
parse_document({:markdown, source}, options)
end
def parse_document({:markdown, markdown}, options) when is_binary(markdown) and is_list(options) do
options = Keyword.put(options, :markdown, markdown)
{:ok, MDEx.new(options) |> Document.run()}
end
def parse_document({:json, json}, options) when is_binary(json) and is_list(options) do
case Jason.decode(json, keys: :atoms!) do
{:ok, decoded} ->
document = %{MDEx.new(options) | nodes: json_to_node(decoded).nodes}
{:ok, document}
{:error, error} ->
{:error, %DecodeError{error: error}}
end
end
defp json_to_node(json) do
{node_type, node} = Map.pop!(json, :node_type)
node_type = Module.concat([node_type])
node = map_nodes(node)
struct(node_type, node)
end
defp map_nodes(%{nodes: nodes} = node) do
%{node | nodes: Enum.map(nodes, &json_to_node/1)}
end
defp map_nodes(node), do: node
@doc """
Same as `parse_document/2` but raises if the parsing fails.
"""
@spec parse_document!(markdown :: String.t() | {:json, String.t()}, MDEx.Document.options()) :: Document.t()
def parse_document!(source, options \\ []) do
case parse_document(source, options) do
{:ok, document} -> document
{:error, error} -> raise error
end
end
@doc """
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 {: .warning}
>
> Consider this function experimental and subject to change.
## Examples
iex> MDEx.parse_fragment("# Elixir")
{:ok, %MDEx.Heading{nodes: [%MDEx.Text{literal: "Elixir"}], level: 1, setext: false, closed: false}}
iex> MDEx.parse_fragment("
"}}
"""
@spec parse_fragment(String.t(), MDEx.Document.options()) :: {:ok, Document.md_node()} | nil
def parse_fragment(markdown, options \\ []) when is_binary(markdown) do
case parse_document(markdown, options) do
{:ok, %Document{nodes: [%MDEx.Paragraph{nodes: [node]}]}} -> {:ok, node}
{:ok, %Document{nodes: [node]}} -> {:ok, node}
_ -> nil
end
end
@doc """
Same as `parse_fragment/2` but raises if the parsing fails or returns `nil`.
> #### Experimental {: .warning}
>
> Consider this function experimental and subject to change.
"""
@spec parse_fragment!(String.t(), MDEx.Document.options()) :: Document.md_node()
def parse_fragment!(markdown, options \\ []) when is_binary(markdown) do
case parse_fragment(markdown, options) do
{:ok, fragment} -> fragment
_ -> raise %InvalidInputError{found: markdown}
end
end
@doc """
Convert Markdown or `MDEx.Document` to HTML.
> #### Phoenix Components Not Supported in to_html {: .warning}
>
> 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, "
{:ok, 'MDEx'}"
## Options
- `:sanitize` - cleans HTML after rendering. Defaults to `MDEx.Document.default_sanitize_options()/0`.
- `keyword` - `t:sanitize_options/0`
- `nil` - 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 `` tags, particularly useful for compiling HTML in LiveView;
"""
@spec safe_html(
String.t(),
options :: [
sanitize: MDEx.Document.sanitize_options() | nil,
escape: [atom()]
]
) :: String.t()
def safe_html(unsafe_html, options \\ []) when is_binary(unsafe_html) and is_list(options) do
sanitize =
options
|> opt([:sanitize], Document.default_sanitize_options())
|> case do
nil ->
nil
options ->
options
|> NimbleOptions.validate!(MDEx.Document.sanitize_options_schema())
|> MDEx.Document.adapt_sanitize_options()
end
escape_content = opt(options, [:escape, :content], true)
escape_curly_braces_in_code = opt(options, [:escape, :curly_braces_in_code], true)
Native.safe_html(unsafe_html, sanitize, escape_content, escape_curly_braces_in_code)
end
defp opt(options, keys, default) do
case get_in(options, keys) do
nil -> default
val -> val
end
end
@document Document.put_options(%MDEx.Document{}, MDEx.Document.default_options())
@typedoc """
A list of [plugins](`m:MDEx.Document#module-pipeline-and-plugins`) to attach to an `MDEx.Document` with `MDEx.new/1`.
Each list member may be one of:
- `t:module/0` - A module that exposes `attach/1`, where the `t:MDEx.Document.t/0` is the only parameter
- `{module, keyword}` - A module exposing `attach/2`, where the `t:MDEx.Document.t/0` is
the first the parameter, and the second parameter is a keyword option list
- `(document -> document)` - A `/1` function that accepts a `t:MDEx.Document.t/0`
"""
@type plugins :: [module() | {module(), keyword()} | (MDEx.Document.t() -> MDEx.Document.t())]
@doc """
Builds a new `MDEx.Document` instance.
`MDEx.Document` is the core data structure used across MDEx. It holds the full CommonMark AST and
exposes rich `Access`, `Enumerable`, and pipeline APIs so you can traverse, manipulate, and enrich
Markdown before turning it into HTML/JSON/XML/Markdown/Delta.
* Pass `:markdown` to include Markdown into the buffer or call `MDEx.Document.put_markdown/2` to add more later.
* Pass any built-in options (`:extension`, `:parse`, `:render`, `:syntax_highlight`, `:sanitize`) to
shape how the document will be parsed and rendered.
* Chain pipeline helpers such as `MDEx.Document.append_steps/2`, `MDEx.Document.update_nodes/3`, or your own plugin
modules to programmatically modify the AST.
* Set `streaming: true` to buffer complete or incomplete Markdown fragments.
Once you finish manipulating the document, call one of the `MDEx.to_*` functions to output the final result to a format
or `MDEx.Document.run/1` to finalize the document and get the updated AST.
## Options
- `:markdown` (`t:String.t/0`) Raw Markdown to parse into the document. Defaults to `""`
- `:plugins` - (`t:plugins/0`) Attach [plugins](`m:MDEx.Document#module-pipeline-and-plugins`) to the document pipeline. Defaults to `[]`
- `:extension` (`t:MDEx.Document.extension_options/0`) Enable extensions. Defaults to `MDEx.Document.default_extension_options/0`
- `:parse` - (`t:MDEx.Document.parse_options/0`) Modify parsing behavior. Defaults to `MDEx.Document.default_parse_options/0`
- `:render` - (`t:MDEx.Document.render_options/0`) Modify rendering behavior. Defaults to `MDEx.Document.default_render_options/0`
- `:syntax_highlight` - (`t:MDEx.Document.syntax_highlight_options/0` | `nil`) Modify syntax highlighting behavior or `nil` to disable. Defaults to `MDEx.Document.default_syntax_highlight_options/0`
- `:sanitize` - (`t:sanitize_options/0` | `nil`) Modify sanitization behavior or `nil` to disable sanitization. Use `MDEx.Document.default_sanitize_options/0` to enable a default set of sanitization options. Defaults to `nil`.
- `:assigns` - (`t:map/0`) A map of assigns for use in pipelines, plugins, and HEEx rendering. Can also be set with `MDEx.Document.assign/2`. Defaults to `%{}`.
Note that `:sanitize` and `:unsafe` are disabled by default. See [Safety](https://hexdocs.pm/mdex/safety.html) for more info.
## Examples
iex> MDEx.new(markdown: "# Hello") |> MDEx.to_html!()
"
\\nInjected"
Using `MDEx.Document.run/1` to process buffered markdown and get the AST:
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"}], level: 1, setext: false},
%MDEx.Heading{nodes: [%MDEx.Text{literal: "Second"}], level: 1, setext: false}
]
Enabling streaming to render partial input as it arrives:
iex> MDEx.new(streaming: true, extension: [strikethrough: true])
...> |> MDEx.Document.put_markdown("~~deprec")
...> |> MDEx.to_html!()
"
deprec
"
Attach [plugins](plugins.html) three different ways:
```elixir
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 new(keyword()) :: Document.t()
def new(options \\ []) do
# TODO: remove :document in v1.0
{document, options} = Keyword.pop(options, :document, nil)
{markdown, options} = Keyword.pop(options, :markdown, nil)
markdown = document || markdown || ""
unless is_binary(markdown) do
raise ArgumentError, ":markdown option must be a binary, got: #{inspect(markdown)}"
end
@document
|> Document.put_markdown(markdown)
|> Document.put_options(options)
end
@doc """
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 anchorize(String.t()) :: String.t()
def anchorize(text), do: Native.text_to_anchor(text)
# TODO: remove in v1.0
defp pop_deprecated_document_option(options) do
{document, options} = Keyword.pop(options, :document)
if !is_nil(document) do
IO.warn("option :document is deprecated, use :markdown instead")
end
{document, options}
end
# TODO: remove in v1.0
defp maybe_apply_document_option(document, markdown) when markdown in [nil, ""] do
document
end
defp maybe_apply_document_option(document, markdown) when is_binary(markdown) do
Document.put_markdown(document, markdown)
end
defp maybe_apply_document_option(document, %Document{} = new_document) do
%{document | nodes: new_document.nodes}
end
defp maybe_apply_document_option(_document, other) do
raise ArgumentError, "option :document must be a binary or %MDEx.Document{}, got: #{inspect(other)}"
end
end