lib/fancy_fences.ex

defmodule FancyFences do
  @moduledoc """
  A tiny wrapper around `ExDoc.Markdown.Earmark` that supports fancy fences.
  """

  @behaviour ExDoc.Markdown

  @impl ExDoc.Markdown
  def available?, do: ExDoc.Markdown.Earmark.available?()

  @impl ExDoc.Markdown
  def to_ast(text, opts) do
    to_ast(text, opts, [])
  end

  defp to_ast(text, opts, applied_processors) do
    {_fences_opts, doc_opts} = Keyword.split(opts, [:fences])

    text
    |> ExDoc.Markdown.Earmark.to_ast(doc_opts)
    |> Enum.reduce([], fn block, acc ->
      acc ++ maybe_apply_fence_processors(block, opts, applied_processors)
    end)
  end

  defp maybe_apply_fence_processors(
         {:blockquote, attrs, content, meta},
         opts,
         applied_processors
       ) do
    content =
      Enum.reduce(content, [], fn block, acc ->
        acc ++ maybe_apply_fence_processors(block, opts, applied_processors)
      end)

    [{:blockquote, attrs, content, meta}]
  end

  defp maybe_apply_fence_processors(
         {:pre, _pre_attrs, [{:code, [class: fence], content, _code_meta}], _pre_meta} = block,
         opts,
         applied_processors
       ) do
    fence_processors = Keyword.get(opts, :fences, %{})

    cond do
      # we want to avoid infinite recursion here, just return the block
      fence in applied_processors ->
        [block]

      # if we have defined a custom fence processor apply it (recursively)
      Map.has_key?(fence_processors, fence) ->
        # a configured fence processor must be an mfa tuple
        {module, function, args} = fence_processors[fence]

        code = Enum.join(content, "\n")

        apply(module, function, [code | args])
        |> to_ast(opts, [fence | applied_processors])

      # in any other case return the original block
      true ->
        [block]
    end
  end

  defp maybe_apply_fence_processors(block, _opts, _applied_processors), do: [block]
end