MDEx.Pipe (MDEx v0.5.0)

View Source

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:

<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.

Summary

Types

Step in a pipeline.

t()

Pipeline state.

Functions

Appends steps to the end of the existing pipeline's step list.

Retrieves an option value from the pipeline.

Retrieves a private value from the pipeline.

Halts the pipeline execution.

Halts the pipeline execution with an exception.

Prepends steps to the beginning of the existing pipeline's step list.

Updates the pipeline's :extension options.

Updates the pipeline's :features options.

Inserts node into the document root at the specified position.

Merges options into the pipeline's existing options.

Updates the pipeline's :parse options.

Stores a value in the pipeline's private storage.

Updates the pipeline's :render options.

Registers a list of valid options that can be used by steps in the pipeline.

Executes the pipeline steps in order.

Updates nodes in the document that match selector.

Updates a value in the pipeline's private storage using a function.

Types

step()

@type step() ::
  (t() -> t())
  | (t() -> {t(), Exception.t()})
  | (t() -> {module(), atom(), [term()]})

Step in a pipeline.

It's a function that receives a MDEx.Pipe.t/0 struct and must return either one of the following:

t()

@type t() :: %MDEx.Pipe{
  current_steps: term(),
  document: MDEx.Document.t(),
  halted: boolean(),
  options: MDEx.options(),
  private: map(),
  registered_options: term(),
  steps: keyword()
}

Pipeline state.

Functions

append_steps(pipe, steps)

@spec append_steps(
  t(),
  keyword(step())
) :: t()

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]

get_option(pipe, key, default \\ nil)

@spec get_option(t(), atom(), term()) :: term()

Retrieves an option value from the pipeline.

get_private(pipe, key, default \\ nil)

@spec get_private(t(), atom(), default) :: term() | default when default: var

Retrieves a private value from the pipeline.

halt(pipe)

@spec halt(t()) :: t()

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

halt(pipe, exception)

@spec halt(t(), Exception.t()) :: {t(), Exception.t()}

Halts the pipeline execution with an exception.

prepend_steps(pipe, steps)

@spec prepend_steps(
  t(),
  keyword(step())
) :: t()

Prepends steps to the beginning of the existing pipeline's step list.

put_extension_options(pipe, options)

@spec put_extension_options(t(), MDEx.extension_options()) :: t()

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

put_features_options(pipe, options)

@spec put_features_options(t(), MDEx.features_options()) :: t()

Updates the pipeline's :features options.

Examples

iex> pipe = MDEx.Pipe.put_features_options(MDEx.new(), sanitize: [add_tags: ["MyComponent"]])
iex> MDEx.Pipe.get_option(pipe, :features)[:sanitize][:add_tags]
["MyComponent"]

put_node_in_document_root(pipe, node, position \\ :top)

@spec put_node_in_document_root(
  t(),
  MDEx.Document.md_node(),
  position :: :top | :bottom
) :: t()

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>"}

put_options(pipe, options)

@spec put_options(
  t(),
  keyword()
) :: t()

Merges options into the pipeline's existing options.

This function handles both built-in options (like :document, :extension, :parse, :render, :features) 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"

put_parse_options(pipe, options)

@spec put_parse_options(t(), MDEx.parse_options()) :: t()

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

put_private(pipe, key, value)

@spec put_private(t(), atom(), term()) :: t()

Stores a value in the pipeline's private storage.

put_render_options(pipe, options)

@spec put_render_options(t(), MDEx.render_options()) :: t()

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

register_options(pipe, options)

@spec register_options(t(), [atom()]) :: t()

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?

run(pipe)

@spec run(t()) :: t()

Executes the pipeline steps in order.

This function is usually not called directly, prefer calling one of the to_* functions in MDEx module.

update_nodes(pipe, selector, fun)

@spec update_nodes(t(), MDEx.Document.selector(), (MDEx.Document.md_node() ->
                                               MDEx.Document.md_node())) ::
  t()

Updates nodes in the document that match selector.

update_private(pipe, key, default, fun)

@spec update_private(t(), key :: atom(), default :: term(), (term() -> term())) :: t()

Updates a value in the pipeline's private storage using a function.