Packages
mdex
0.6.0
0.13.3
0.13.2
0.13.1
0.13.0
0.12.5
retired
0.12.4
0.12.3
0.12.2
0.12.1
0.12.0
0.11.7
0.11.6
0.11.5
0.11.4
0.11.3
0.11.2
0.11.1
0.11.0
0.10.0
0.9.4
0.9.3
0.9.2
0.9.1
0.9.0
0.8.6
0.8.5
0.8.4
0.8.3
0.8.2
0.8.1
0.8.0
0.7.5
0.7.4
0.7.3
0.7.2
0.7.1
0.7.0
0.6.2
0.6.1
0.6.0
0.5.0
0.4.3
0.4.2
0.4.1
0.4.0
0.3.3
0.3.2
0.3.1
0.3.0
0.2.0
0.1.18
0.1.17
0.1.16
0.1.15
0.1.14
0.1.13
0.1.12
0.1.11
0.1.10
0.1.9
0.1.8
0.1.7
0.1.6
0.1.5
0.1.4
0.1.3
0.1.2
0.1.1
0.1.0
Fast and extensible Markdown for Elixir
Security advisory:
This version has known vulnerabilities.
View advisories
Current section
Files
Jump to
Current section
Files
lib/mdex/pipe.ex
defmodule MDEx.Pipe do
@moduledoc """
MDEx.Pipe 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:
document = \"\"\"
# 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()
|> MDExMermaid.attach(version: "11")
|> MDEx.to_html(document: document)
## Writing Plugins
To understand how it works, let's write that Mermaid plugin showed above.
In order to render Mermaid diagrams, we need to inject this `<script>` into the document,
as outlined in their [docs](https://mermaid.js.org/intro/#installation):
<script type="module">
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
mermaid.initialize({ startOnLoad: true });
</script>
The `:version` option will be options to let users load a specific version,
but it will default to the latest version:
MDEx.new() |> MDExMermaid.attach()
Or to customize the version:
MDEx.new() |> MDExMermaid.attach(version: "11")
Let's get into the actual code, with comments to explain each part:
defmodule MDExMermaid do
alias MDEx.Pipe
@latest_version "11"
def attach(pipe, options \\ []) do
pipe
# register option with prefix `:mermaid_` to avoid conflicts with other plugins
|> Pipe.register_options([:mermaid_version])
# merge all options given by users
|> Pipe.put_options(mermaid_version: options[:version])
# actual steps to manipulate the document
# see respective Pipe functions for more info
|> Pipe.append_steps(enable_unsafe: &enable_unsafe/1)
|> Pipe.append_steps(inject_script: &inject_script/1)
|> Pipe.append_steps(update_code_blocks: &update_code_blocks/1)
end
defp enable_unsafe(pipe) do
Pipe.put_render_options(pipe, unsafe_: true)
end
defp inject_script(pipe) do
version = Pipe.get_option(pipe, :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>
\"\"\"
}
Pipe.put_node_in_document_root(pipe, script_node)
end
defp update_code_blocks(pipe) do
selector = fn
%MDEx.CodeBlock{info: "mermaid"} -> true
_ -> false
end
Pipe.update_nodes(
pipe,
selector,
&%MDEx.HtmlBlock{literal: "<pre class=\"mermaid\">\#\{&1.literal}</pre>", nodes: &1.nodes}
)
end
end
Now whenever that plugin is attached to a pipeline,
MDEx will run all functions defined in the `attach/1` function.
"""
import MDEx.Document, only: [is_fragment: 1]
@built_in_options [
:document,
:extension,
:parse,
:render,
:syntax_highlight,
:sanitize
]
defstruct document: nil,
options: [
document: "",
extension: [],
parse: [],
render: [],
syntax_highlight: [],
sanitize: nil
],
registered_options: MapSet.new(),
halted: false,
steps: [],
current_steps: [],
private: %{}
@typedoc """
Pipeline state.
"""
@type t :: %__MODULE__{
document: MDEx.Document.t(),
options: MDEx.options(),
halted: boolean(),
steps: keyword(),
private: map()
}
@typedoc """
Step in a pipeline.
It's a function that receives a `t:MDEx.Pipe.t/0` struct and must return either one of the following:
- a `t:MDEx.Pipe.t/0` struct
- a tuple with a `t:MDEx.Pipe.t/0` struct and an `t:Exception.t/0` as `{pipe, exception}`
- a tuple with a module, function and arguments which will be invoked with `apply/3`
"""
@type step() ::
(t() -> t())
| (t() -> {t(), Exception.t()})
| (t() -> {module(), atom(), [term()]})
@doc """
Registers a list of valid options that can be used by steps in the pipeline.
## Examples
iex> pipe = MDEx.new()
iex> pipe = MDEx.Pipe.register_options(pipe, [:mermaid_version])
iex> pipe = MDEx.Pipe.put_options(pipe, mermaid_version: "11")
iex> pipe.options[:mermaid_version]
"11"
iex> MDEx.new(rendr: [unsafe_: true])
** (ArgumentError) unknown option :rendr. Did you mean :render?
"""
@spec register_options(t(), [atom()]) :: t()
def register_options(%MDEx.Pipe{} = pipe, options) when is_list(options) do
update_in(pipe.registered_options, &MapSet.union(&1, MapSet.new(options)))
end
@doc """
Merges options into the pipeline's existing options.
This function handles both built-in options (`:document`, `:extension`, `:parse`, `:render`, `:syntax_highlight`, and `:sanitize`)
and user-defined options that have been registered with `register_options/2`.
## Examples
iex> pipe = MDEx.Pipe.register_options(MDEx.new(), [:custom_option])
iex> pipe = MDEx.Pipe.put_options(pipe, [
...> document: "# Hello",
...> extension: [table: true],
...> custom_option: "value"
...> ])
iex> MDEx.Pipe.get_option(pipe, :document)
"# Hello"
iex> MDEx.Pipe.get_option(pipe, :extension)[:table]
true
iex> MDEx.Pipe.get_option(pipe, :custom_option)
"value"
Built-in options are validated against their respective schemas:
iex> try do
...> MDEx.Pipe.put_options(MDEx.new(), [extension: [invalid: true]])
...> rescue
...> NimbleOptions.ValidationError -> :error
...> end
:error
"""
@spec put_options(t(), keyword()) :: t()
def put_options(%MDEx.Pipe{} = pipe, options) when is_list(options) do
validate_options(pipe, options)
Enum.reduce(options, pipe, fn
{name, options}, acc when name in @built_in_options ->
put_built_in_options(acc, [{name, options}])
{name, value}, acc ->
put_user_options(acc, [{name, value}])
end)
end
@doc false
def put_built_in_options(%MDEx.Pipe{} = pipe, options) when is_list(options) do
options = Keyword.take(options, @built_in_options)
Enum.reduce(options, pipe, fn
{:document, value}, acc ->
NimbleOptions.validate!([{:document, value}], MDEx.options_schema())
%{acc | options: put_in(pipe.options, [:document], value)}
{:extension, options}, acc ->
put_extension_options(acc, options)
{:render, options}, acc ->
put_render_options(acc, options)
{:parse, options}, acc ->
put_parse_options(acc, options)
{:syntax_highlight, options}, acc ->
put_syntax_highlight_options(acc, options)
{:sanitize, options}, acc ->
put_sanitize_options(acc, options)
end)
end
@doc false
def put_user_options(%MDEx.Pipe{} = pipe, options) when is_list(options) do
options = Keyword.take(options, Keyword.keys(options) -- MDEx.built_in_options())
validate_options(pipe, options)
%{pipe | options: Keyword.merge(pipe.options, options)}
end
@doc false
@spec validate_options(t(), keyword()) :: boolean()
def validate_options(%MDEx.Pipe{} = pipe, options) do
validate_options(options, pipe.registered_options)
end
def validate_options([{name, _value} | rest], registered) do
if name in registered do
validate_options(rest, registered)
else
case did_you_mean(Atom.to_string(name), registered) do
{similar, score} when score > 0.8 ->
raise ArgumentError, "unknown option #{inspect(name)}. Did you mean :#{similar}?"
_ ->
raise ArgumentError, "unknown option #{inspect(name)}"
end
end
end
def validate_options([], _registered) do
true
end
defp did_you_mean(option, registered) do
registered
|> Enum.map(&to_string/1)
|> Enum.reduce({nil, 0}, &max_similar(&1, option, &2))
end
defp max_similar(option, registered, {_, current} = best) do
score = String.jaro_distance(option, registered)
if score < current, do: best, else: {option, score}
end
@doc """
Appends steps to the end of the existing pipeline's step list.
## Examples
* Update an `:extension` option:
iex> pipe = MDEx.new()
iex> pipe = MDEx.Pipe.append_steps(
...> pipe,
...> enable_tables: fn pipe -> MDEx.Pipe.put_extension_options(pipe, table: true) end
...> )
iex> pipe |> MDEx.Pipe.run() |> MDEx.Pipe.get_option(:extension)
[table: true]
"""
@spec append_steps(t(), keyword(step())) :: t()
def append_steps(pipe, steps) do
%{
pipe
| steps: pipe.steps ++ steps,
current_steps: pipe.current_steps ++ Keyword.keys(steps)
}
end
@doc """
Prepends steps to the beginning of the existing pipeline's step list.
"""
@spec prepend_steps(t(), keyword(step())) :: t()
def prepend_steps(pipe, steps) do
%{
pipe
| steps: steps ++ pipe.steps,
current_steps: Keyword.keys(steps) ++ pipe.current_steps
}
end
@doc """
Updates the pipeline's `:extension` options.
## Examples
iex> pipe = MDEx.Pipe.put_extension_options(MDEx.new(), table: true)
iex> MDEx.Pipe.get_option(pipe, :extension)[:table]
true
"""
@spec put_extension_options(t(), MDEx.extension_options()) :: t()
def put_extension_options(%MDEx.Pipe{} = pipe, options) when is_list(options) do
NimbleOptions.validate!(options, MDEx.extension_options_schema())
%{
pipe
| options:
update_in(pipe.options, [:extension], fn extension ->
Keyword.merge(extension || [], options)
end)
}
end
@doc """
Updates the pipeline's `:render` options.
## Examples
iex> pipe = MDEx.Pipe.put_render_options(MDEx.new(), escape: true)
iex> MDEx.Pipe.get_option(pipe, :render)[:escape]
true
"""
@spec put_render_options(t(), MDEx.render_options()) :: t()
def put_render_options(%MDEx.Pipe{} = pipe, options) when is_list(options) do
NimbleOptions.validate!(options, MDEx.render_options_schema())
%{
pipe
| options:
update_in(pipe.options, [:render], fn render ->
Keyword.merge(render || [], options)
end)
}
end
@doc """
Updates the pipeline's `:parse` options.
## Examples
iex> pipe = MDEx.Pipe.put_parse_options(MDEx.new(), smart: true)
iex> MDEx.Pipe.get_option(pipe, :parse)[:smart]
true
"""
@spec put_parse_options(t(), MDEx.parse_options()) :: t()
def put_parse_options(%MDEx.Pipe{} = pipe, options) when is_list(options) do
NimbleOptions.validate!(options, MDEx.parse_options_schema())
%{
pipe
| options:
update_in(pipe.options, [:parse], fn parse ->
Keyword.merge(parse || [], options)
end)
}
end
@doc """
Updates the pipeline's `:syntax_highlight` options.
## Examples
iex> pipe = MDEx.Pipe.put_syntax_highlight_options(MDEx.new(), formatter: :html_linked)
iex> MDEx.Pipe.get_option(pipe, :syntax_highlight)[:formatter]
:html_linked
"""
@spec put_syntax_highlight_options(t(), MDEx.syntax_highlight_options()) :: t()
def put_syntax_highlight_options(%MDEx.Pipe{} = pipe, options) when is_list(options) do
NimbleOptions.validate!(options, MDEx.syntax_highlight_options_schema())
%{
pipe
| options:
update_in(pipe.options, [:syntax_highlight], fn syntax_highlight ->
Keyword.merge(syntax_highlight || [], options)
end)
}
end
@doc """
Updates the pipeline's `:sanitize` options.
## Examples
iex> pipe = MDEx.Pipe.put_sanitize_options(MDEx.new(), add_tags: ["MyComponent"])
iex> MDEx.Pipe.get_option(pipe, :sanitize)[:add_tags]
["MyComponent"]
"""
@spec put_sanitize_options(t(), MDEx.sanitize_options()) :: t()
def put_sanitize_options(%MDEx.Pipe{} = pipe, options) when is_list(options) do
NimbleOptions.validate!(options, MDEx.sanitize_options_schema())
%{
pipe
| options:
update_in(pipe.options, [:sanitize], fn sanitize ->
Keyword.merge(sanitize || [], options)
end)
}
end
@doc """
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> pipe = MDEx.new()
iex> pipe = MDEx.Pipe.append_steps(
...> pipe,
...> append_node: fn pipe ->
...> html_block = %MDEx.HtmlBlock{literal: "<p>Hello</p>"}
...> MDEx.Pipe.put_node_in_document_root(pipe, html_block, :bottom)
...> end)
iex> MDEx.to_html(pipe, document: "# Doc", render: [unsafe_: true])
{:ok, "<h1>Doc</h1>\\n<p>Hello</p>"}
"""
@spec put_node_in_document_root(t(), MDEx.Document.md_node(), position :: :top | :bottom) :: t()
def put_node_in_document_root(pipe, node, position \\ :top)
def put_node_in_document_root(%MDEx.Pipe{document: %MDEx.Document{} = document} = pipe, node, :top = _position) do
document =
case is_fragment(node) do
true ->
nodes = [node | document.nodes]
%{document | nodes: nodes}
false ->
document
end
%{pipe | document: document}
end
def put_node_in_document_root(%MDEx.Pipe{document: %MDEx.Document{} = document} = pipe, node, :bottom = _position) do
document =
case is_fragment(node) do
true ->
nodes = document.nodes ++ [node]
%{document | nodes: nodes}
false ->
raise """
expected a fragment node as %MDEx.Heading{}
Got:
#{inspect(node)}
"""
end
%{pipe | document: document}
end
@doc """
Updates nodes in the document that match `selector`.
"""
@spec update_nodes(t(), MDEx.Document.selector(), (MDEx.Document.md_node() -> MDEx.Document.md_node())) :: t()
def update_nodes(%MDEx.Pipe{} = pipe, selector, fun) when is_function(fun, 1) do
document =
update_in(pipe.document, [:document, Access.key!(:nodes), Access.all(), selector], fn node ->
fun.(node)
end)
%{pipe | document: document}
end
@doc """
Halts the 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> pipe = MDEx.Pipe.halt(MDEx.new())
iex> pipe.halted
true
"""
@spec halt(t()) :: t()
def halt(%MDEx.Pipe{} = pipe) do
put_in(pipe.halted, true)
end
@doc """
Halts the pipeline execution with an exception.
"""
@spec halt(t(), Exception.t()) :: {t(), Exception.t()}
def halt(%MDEx.Pipe{} = pipe, %_{__exception__: true} = exception) do
{put_in(pipe.halted, true), exception}
end
@doc """
Retrieves an option value from the pipeline.
"""
@spec get_option(t(), atom(), term()) :: term()
def get_option(%MDEx.Pipe{} = pipe, key, default \\ nil) when is_atom(key) do
Keyword.get(pipe.options, key, default)
end
@doc """
Retrieves a private value from the pipeline.
"""
@spec get_private(t(), atom(), default) :: term() | default when default: var
def get_private(%MDEx.Pipe{} = pipe, key, default \\ nil) when is_atom(key) do
Map.get(pipe.private, key, default)
end
@doc """
Updates a value in the pipeline's private storage using a function.
"""
@spec update_private(t(), key :: atom(), default :: term(), (term() -> term())) :: t()
def update_private(%MDEx.Pipe{} = pipe, key, default, fun) when is_atom(key) and is_function(fun, 1) do
update_in(pipe.private, &Map.update(&1, key, default, fun))
end
@doc """
Stores a value in the pipeline's private storage.
"""
@spec put_private(t(), atom(), term()) :: t()
def put_private(%MDEx.Pipe{} = pipe, key, value) when is_atom(key) do
put_in(pipe.private[key], value)
end
@doc false
# parse options.document and put into pipe.document
def resolve_document(pipe) do
case MDEx.parse_document(pipe.options[:document], pipe.options) do
{:ok, document} ->
%{pipe | document: document}
{:error, error} ->
raise """
failed to parse document
expected a valid String or %MDEx.Document{} in options.document
Got:
#{inspect(pipe.options.document)}
Error:
#{inspect(error)}
"""
end
end
@doc """
Executes the pipeline steps in order.
This function is usually not called directly,
prefer calling one of the `to_*` functions in `MDEx` module.
"""
@spec run(t()) :: t()
def run(%MDEx.Pipe{} = pipe) do
pipe
|> resolve_document()
|> do_run()
end
defp do_run(%{current_steps: [step | rest]} = pipe) do
step = Keyword.fetch!(pipe.steps, step)
# TODO: run_error
case run_step(step, pipe) do
{%MDEx.Pipe{halted: true} = pipe, exception} ->
{pipe, exception}
%MDEx.Pipe{halted: true} = pipe ->
pipe
%MDEx.Pipe{} = pipe ->
do_run(%{pipe | current_steps: rest})
end
end
defp do_run(%{current_steps: []} = pipe) do
pipe
end
defp run_step(step, state) when is_function(step, 1) do
step.(state)
end
defp run_step({mod, fun, args}, state) when is_atom(mod) and is_atom(fun) and is_list(args) do
apply(mod, fun, [state | args])
end
end