lib/datocms/structured_text.ex

defmodule DatoCMS.StructuredText do
  @moduledoc """
  Utilities for rendering DatoCMS StructuredText data.
  """

  defmodule CustomRenderersError do
    defexception [:message]
  end

  @mark_nodes %{
    "code" => "code",
    "emphasis" => "em",
    "highlight" => "mark",
    "strikethrough" => "del",
    "strong" => "strong",
    "underline" => "u"
  }

  @doc """
  Transforms the data from a Structured Text field in a DatoCMS GraphQL
  response into HTML.

  ## Options

    * `:renderers` - Custom HTML renderers (see below),
    * `:data` - Any data that you want to be passed in to your custom renderers.

  The rendering system is recursive, calling back into `render/3` as it
  iterates over child nodes.

  The default rendering turns this:

  ```json
  %{
    value: %{
      schema: "dast",
      document: %{
        type: "root",
        children: [
          %{
            type: "paragraph",
            children: [
              {
                type: "span",
                value: "Hi There"
              }
            ]
          }
        ]
      }
    }
  }
  ```

  into this:

  ```html
  <p>Hi There</p>
  ```

  By default, the types are transformed as follows:

  | type       | result                                |
  |------------|---------------------------------------|
  | root       | the rendered children                 |
  | paragraph  | `<p>...</p>`                          |
  | span       | the node value                        |
  | heading    | `<hn>...</hn>` where `n` is the level |
  | link       | `<a ...>...</a>`                      |
  | block      | requires custom renderer (see below)  |
  | inlineItem | requires custom renderer (see below)  |
  | itemLink   | requires custom renderer (see below)  |

  Note that text styling is transformed as follows:

  | mark          | tag    |
  |---------------|--------|
  | code          | code   |
  | emphasis      | em     |
  | highlight     | mark   |
  | strikethrough | del    |
  | strong        | strong |
  | underline     | u      |

  ## Optional Custom Renderers

  All of the standard render methods can be overriden.

  This is achieved by passing custom renderers in the second `options`
  parameter:

  ```elixir
  import DatoCMS.StructuredText, only: [to_html: 2, render: 3]

  def custom_paragraph(node, dast, options) do
    ["<section>"] ++
      Enum.map(node.children, &(render(&1, dast, options))) ++
      ["</section>"]
  end

  options = %{
    renderers: %{
      render_paragraph: &custom_paragraph/3 # <-- Pass the custom renderer
    }
  }

  result = to_html(structured_text, options)
  ```

  In this example, all paragraphs will be renderered using the custom
  function provided.

  The custom renderers that can be used in this way are the following:

  * `render_blockquote/3`,
  * `render_bulleted_list/3`,
  * `render_code/3`,
  * `render_emphasis/3`,
  * `render_heading/3`,
  * `render_highlight/3`,
  * `render_link/3`,
  * `render_numbered_list/3`,
  * `render_paragraph/3`,
  * `render_span/3`.
  * `render_strikethrough/3`,
  * `render_strong/3`,
  * `render_underline/3`.

  Custom renderers receive 3 parameters:

  * `node` - the node to be rendered,
  * `dast` - the original value supplied to `DatoCMS.StructuredText.to_html/2`,
  * `options` - the options supplied to `DatoCMS.StructuredText.to_html/2`.

  Renderers can either return a String or a List of Strings.
  In the latter case, the Strings are joined together in the return value of
  `to_html/2`.

  ## Required Renderers

  If your structured text includes blocks, inline items
  or item links, you'll need to supply a custom renderer as it doesn't make sense
  to have a default renerer isn these cases.

  * `render_block/3` for blocks,
  * `render_inline_record/3` for inline items,
  * `render_link_to_record/4` for item links.

  Note: all custom renderers must accept 3 parameters,
  except `render_link_to_record` which receives both the node that is linked
  *and* the item that is linked to, plus the `dast` and `options`.
  """
  @spec to_html(map(), map() | nil) :: String.t()
  def to_html(dast, options \\ %{})
  def to_html(
    %{value: %{schema: "dast", document: document}} = dast, options
  ) do
    render(document, dast, options)
    |> Enum.join("")
  end
  def to_html(%{schema: "dast", document: _document} = value, options) do
    IO.warn """
    The value you supplied to `DatoCMS.StructuredText.to_html/2` is incorrect.
    Please supply the *whole* value for the StructuredText field in the response
    not just the `value` part.
    """
    to_html(%{value: value}, options)
  end
  def to_html(%{"value" => _value}, _options) do
    message = """
    The StructuredText field value you have passed to
    `DatoCMS.StructuredText.to_html/2` seems to be a Map with Strings as keys.
    Please pass a Map with Atoms as keys.
    """

    raise ArgumentError.exception(message)
  end
  def to_html(data, _options) do
    message = """
    The value passed to `DatoCMS.StructuredText.to_html/2` is incorrect.
    You should supply the resulting value from a GraphQL query
    for a StructuredText field.
    Something like this:

    ```elixir
    %{value: %{schema: "dast", document: %{type: "root", children: [...
    ```

    You supplied the following:
    #{inspect(data)}
    """

    raise ArgumentError.exception(message)
  end

  def render(%{type: "root"} = node, dast, options) do
    Enum.flat_map(node.children, &(render(&1, dast, options)))
  end

  def render(%{type: "blockquote"} = node, dast, options) do
    case renderer(options, :render_blockquote) do
      {:ok, renderer} ->
        renderer.(node, dast, options) |> list()
      _ ->
        caption = if Map.has_key?(node, :attribution) do
            ["<figcaption>— #{node.attribution}</figcaption>"]
          else
            []
          end

        ["<figure>"] ++
          ["<blockquote>"] ++
          Enum.flat_map(node.children, &(render(&1, dast, options))) ++
          ["</blockquote>"] ++
          caption ++
          ["</figure>"]
    end
  end

  def render(%{type: "code", code: code} = node, dast, options) do
    case renderer(options, :render_code) do
      {:ok, renderer} ->
        renderer.(node, dast, options) |> list()
    _ ->
      ["<pre>", code, "</pre>"]
    end
  end

  def render(%{type: "list", style: "bulleted"} = node, dast, options) do
    case renderer(options, :render_bulleted_list) do
      {:ok, renderer} ->
        renderer.(node, dast, options) |> list()
      _ ->
        ["<ul>"] ++
          Enum.flat_map(
            node.children,
            fn list_item ->
              ["<li>"] ++
                Enum.flat_map(list_item.children, &(render(&1, dast, options))) ++
                ["</li>"]
            end
          ) ++
          ["</ul>"]
    end
  end

  def render(%{type: "list", style: "numbered"} = node, dast, options) do
    case renderer(options, :render_numbered_list) do
      {:ok, renderer} ->
        renderer.(node, dast, options) |> list()
      _ ->
        ["<ol>"] ++
          Enum.flat_map(
            node.children,
            fn list_item ->
              ["<li>"] ++
                Enum.flat_map(list_item.children, &(render(&1, dast, options))) ++
                ["</li>"]
            end
          ) ++
          ["</ol>"]
    end
  end

  def render(%{type: "paragraph"} = node, dast, options) do
    case renderer(options, :render_paragraph) do
      {:ok, renderer} ->
        renderer.(node, dast, options) |> list()
      _ ->
        inner = Enum.flat_map(node.children, &(render(&1, dast, options)))
        ["<p>"] ++ inner ++ ["</p>"]
    end
  end

  def render(%{type: "heading"} = node, dast, options) do
    case renderer(options, :render_heading) do
      {:ok, renderer} ->
        renderer.(node, dast, options) |> list()
      _ ->
        tag = "h#{node.level}"
        inner = Enum.flat_map(node.children, &(render(&1, dast, options)))
        ["<#{tag}>"] ++ inner ++ ["</#{tag}>"]
    end
  end

  def render(%{type: "link"} = node, dast, options) do
    case renderer(options, :render_link) do
      {:ok, renderer} ->
        renderer.(node, dast, options) |> list()
      _ ->
        meta = render_link_meta(node, dast, options)
        inner = Enum.flat_map(node.children, &(render(&1, dast, options)))
        [~s(<a href="#{node.url}"#{meta}>)] ++ inner ++ ["</a>"]
    end
  end

  def render(%{type: "span", marks: [mark | marks]} = node, dast, options) do
    renderer_key = :"render_#{mark}"
    case renderer(options, renderer_key) do
      {:ok, renderer} ->
        renderer.(node, dast, options) |> list()
      _ ->
        simplified = Map.put(node, :marks, marks)
        inner = render(simplified, dast, options)
        node = @mark_nodes[mark]
        ["<#{node}>"] ++ inner ++ ["</#{node}>"]
    end
  end

  def render(%{type: "span"} = node, _dast, _options) do
    [node.value]
  end

  def render(%{type: "inlineItem"} = node, dast, options) do
    with {:ok, renderer} <- renderer(options, :render_inline_record),
         {:ok, item} <- linked_item(node, dast) do
      arity = arity(renderer)
      case arity do
        1 ->
          deprecation_warning(:render_inline_record, 1, 3)
          renderer.(item) |> list()
        3 ->
          renderer.(item, dast, options) |> list()
        _ ->
          message = """
          Custom renderers for inline records take 3 parameters,
          you passed a function with #{arity} parameters
          as `render_inline_record`.
          """
          raise CustomRenderersError, message: message
      end
    else
      {:error, message} ->
        raise CustomRenderersError, message: message
    end
  end

  def render(%{type: "itemLink"} = node, dast, options) do
    with {:ok, renderer} <- renderer(options, :render_link_to_record),
         {:ok, item} <- linked_item(node, dast) do
      arity = arity(renderer)
      case arity do
        2 ->
          deprecation_warning(:render_link_to_record, 2, 4)
          renderer.(item, node) |> list()
        4 ->
          renderer.(item, node, dast, options) |> list()
        _ ->
          message = """
          Custom renderers for links to records take 4 parameters,
          you passed a function with #{arity} parameters
          as `render_link_to_record`.
          """
          raise CustomRenderersError, message: message
      end
    else
      {:error, message} ->
        raise CustomRenderersError, message: message
    end
  end

  def render(%{type: "block"} = node, dast, options) do
    with {:ok, renderer} <- renderer(options, :render_block),
         {:ok, item} <- block(node, dast) do
      arity = arity(renderer)
      case arity do
        1 ->
          deprecation_warning(:render_block, 1, 3)
          renderer.(item) |> list()
        3 ->
          renderer.(item, dast, options) |> list()
        _ ->
          message = """
          Custom renderers for blocks take 3 parameters,
          you passed a function with #{arity} parameters
          as `render_block`.
          """
          raise CustomRenderersError, message: message
      end
    else
      {:error, message} ->
        raise CustomRenderersError, message: message
    end
  end

  def render_link_meta(%{meta: meta}, _dast, _options) do
    items =
      meta
      |> Enum.map(fn entry ->
        ~s(#{entry.id}="#{entry.value}")
      end)

    " " <> Enum.join(items, " ")
  end

  def render_link_meta(_node, _dast, _options), do: ""

  defp renderer(%{renderers: renderers}, name) do
    renderer = renderers[name]
    if renderer do
      {:ok, renderer}
    else
      {
        :error,
        """
        No `#{name}` function supplied in options.renders

        Supplied renderers:
        #{inspect(Map.keys(renderers))}
        """
      }
    end
  end
  defp renderer(options, _name) do
    {
      :error,
      """
      No `:renderers` supplied in options:

      options: #{inspect(Map.keys(options))}
      """
    }
  end

  defp block(%{item: item_id} = node, %{blocks: blocks}) do
    item = Enum.find(blocks, &(&1.id == item_id))
    if item do
      {:ok, item}
    else
    {
      :error,
      """
      Linked item `#{item_id}` not found in `dast.blocks`.

      A "block" node requires item #{node.item} to be present in `dast.blocks`.

      `node` contents:
      #{inspect(node)}

      `links` contents:
      #{inspect(blocks)}
      """
    }
    end
  end
  defp block(node, dast) do
    {
      :error,
      """
      No `:blocks` supplied in dast.

      A "block" node requires `:blocks` to be present in `dast`.

      `node` contents:
      #{inspect(node)}

      `dast` contents:
      #{inspect(dast)}
      """
    }
  end

  defp linked_item(%{item: item_id} = node, %{links: links}) do
    item = Enum.find(links, &(&1.id == item_id))
    if item do
      {:ok, item}
    else
    {
      :error,
      """
      Linked item `#{item_id}` not found in `dast.links`.

      A node of type `#{node.type}` requires item #{node.item} to be present in the `dast`.

      `node` contents:
      #{inspect(node)}

      `links` contents:
      #{inspect(links)}
      """
    }
    end
  end
  defp linked_item(node, dast) do
    {
      :error,
      """
      No `:links` supplied in dast.

      A node of type `#{node.type}` requires `:links` to be present in the `dast`.

      `node` contents:
      #{inspect(node)}

      `dast` contents:
      #{inspect(dast)}
      """
    }
  end

  defp list(item) when is_list(item), do: item
  defp list(item), do: [item]

  defp arity(fun), do: :erlang.fun_info(fun)[:arity]

  defp deprecation_warning(renderer, old, new) do
    IO.warn """
    Passing custom renderers to `#{renderer}` with #{old} parameter#{if old > 1, do: "s"}
    to DatoCMS.StructuredText.to_html/2 is deprecated.

    Custom renderers for `#{renderer}` now take #{new} parameters.
    """
  end
end