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

Hi There

``` By default, the types are transformed as follows: | type | result | |------------|---------------------------------------| | root | the rendered children | | paragraph | `

...

` | | span | the node value | | heading | `...` where `n` is the level | | link | `...` | | 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 ["
"] ++ Enum.map(node.children, &(render(&1, dast, options))) ++ ["
"] 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 ["
— #{node.attribution}
"] else [] end ["
"] ++ ["
"] ++ Enum.flat_map(node.children, &(render(&1, dast, options))) ++ ["
"] ++ caption ++ ["
"] 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() _ -> ["
", code, "
"] 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() _ -> [""] 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() _ -> ["
    "] ++ Enum.flat_map( node.children, fn list_item -> ["
  1. "] ++ Enum.flat_map(list_item.children, &(render(&1, dast, options))) ++ ["
  2. "] end ) ++ ["
"] 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))) ["

"] ++ inner ++ ["

"] 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 ++ [""] 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()] ++ inner ++ [""] 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 ++ [""] 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