defmodule Sanity.Components.PortableText do @moduledoc ~S''' For rending [Sanity CMS portable text](https://www.sanity.io/docs/presenting-block-text). ## Examples ### Basic example use Phoenix.Component # ... assigns = %{ portable_text: [ %{ _key: "f71173c80e3a", _type: "block", children: [%{_key: "d6c419dcf485", _type: "span", marks: [], text: "Test paragraph."}], mark_defs: [], style: "normal" } ] } ~H"" ### Custom rendering defmodule CustomBlock do use Phoenix.Component use Sanity.Components.PortableText @impl true def block(%{value: %{style: "normal"}} = assigns) do ~H"""
<%= render_slot(@inner_block) %>
""" end def block(assigns), do: super(assigns) end Then render the component like: ~H"" Similarly, marks and types can be customized by defining `mark/1` and `type/1` functions in the module. ''' use Phoenix.Component require Logger defmodule Behaviour do @moduledoc false @callback block(map()) :: Phoenix.LiveView.Rendered.t() @callback mark(map()) :: Phoenix.LiveView.Rendered.t() @callback type(map()) :: Phoenix.LiveView.Rendered.t() end @behaviour Behaviour defmacro __using__([]) do quote do @behaviour Sanity.Components.PortableText.Behaviour def block(assigns), do: Sanity.Components.PortableText.block(assigns) def mark(assigns), do: Sanity.Components.PortableText.mark(assigns) def type(assigns), do: Sanity.Components.PortableText.type(assigns) defoverridable Sanity.Components.PortableText.Behaviour end end @doc """ Renders Sanity CMS portable text. See module doc for examples. """ attr :value, :any, required: true attr :mod, :atom, default: __MODULE__ def portable_text(assigns) do ~H""" <%= for group <- blocks_to_nested_lists(@value) do %><.blocks_or_list mod={@mod} value={group} /><% end %> """ end @doc """ Converts list of blocks to plain text. """ def to_plain_text(blocks) when is_list(blocks) do blocks |> Enum.filter(fn %{_type: "block", children: [_ | _]} -> true _ -> false end) |> Enum.map(fn %{children: children} -> children |> Enum.map(fn %{_type: "span", text: text} -> text _ -> "" end) |> Enum.join(" ") end) |> Enum.join("\n\n") end defp blocks_to_nested_lists(blocks) do blocks |> Enum.chunk_by(fn block -> block[:list_item] end) |> Enum.map(fn [%{list_item: list_item} | _] = items when not is_nil(list_item) -> nest_list(items, %{type: list_item, level: 1, items: []}) [%{} | _] = blocks -> %{type: "blocks", items: blocks} end) end defp nest_list([], acc) do update_in(acc.items, &Enum.reverse/1) end defp nest_list([%{level: level} = item | rest], %{level: level} = acc) do nest_list(rest, prepend_to_list(item, acc)) end defp nest_list([%{level: level, list_item: list_item} | _] = items, acc) when level > acc.level do {deeper_items, rest} = Enum.split_while(items, fn i -> i.level > acc.level end) sub_list = nest_list(deeper_items, %{type: list_item, level: acc.level + 1, items: []}) acc = case acc do %{items: [last_item | acc_rest]} -> put_in(acc.items, [Map.put(last_item, :sub_list, sub_list) | acc_rest]) %{items: []} -> empty_list_block(%{level: acc.level + 1, list_item: acc.type}) |> Map.put(:sub_list, sub_list) |> prepend_to_list(acc) end nest_list(rest, acc) end defp empty_list_block(%{level: level, list_item: list_item}) do %{ _key: :crypto.strong_rand_bytes(6) |> Base.encode16(case: :lower), _type: "block", children: [], level: level, list_item: list_item, mark_defs: [], style: "normal" } end defp prepend_to_list(item, %{items: items} = list), do: %{list | items: [item | items]} defp render_with(assigns) do {func, assigns} = Map.pop!(assigns, :func) apply(assigns.mod, func, [assigns]) end defp put_shared_props(assigns) do assign(assigns, :shared_props, Map.take(assigns, [:mod, :value])) end defp blocks_or_list(%{value: %{type: "blocks"}} = assigns) do ~H""" <%= for block <- @value.items do %> <.render_with mod={@mod} func={:type} value={block} /> <% end %> """ end defp blocks_or_list(%{value: %{type: "bullet"}} = assigns) do ~H""" """ end defp blocks_or_list(%{value: %{type: "number"}} = assigns) do ~H"""
    <%= for item <- @value.items do %> <.list_item mod={@mod} value={item} /> <% end %>
""" end defp list_item(assigns) do assigns = put_shared_props(assigns) ~H"""
  • <.children {@shared_props} /> <%= if @value[:sub_list] do %><.blocks_or_list mod={@mod} value={@value.sub_list} /><% end %>
  • """ end defp children(assigns) do assigns = put_shared_props(assigns) ~H""" <%= for child <- @value.children do %><.marks marks={child.marks} {@shared_props}><%= child.text %><% end %> """ end @doc false @impl true def type(%{value: %{_type: "block"}} = assigns) do assigns = put_shared_props(assigns) ~H""" <.render_with func={:block} {@shared_props}> <.children {@shared_props} /> """ end def type(%{value: %{_type: type}} = assigns) do Logger.warn("unknown type: #{inspect(type)}") ~H"" end @doc false @impl true def block(%{value: %{_type: "block", style: style}} = assigns) when style in ["blockquote", "h1", "h2", "h3", "h4", "h5", "h6"] do ~H""" <.dynamic_tag name={@value.style}><%= render_slot(@inner_block) %> """ end def block(%{value: %{_type: "block", style: "normal"}} = assigns) do ~H"""

    <%= render_slot(@inner_block) %>

    """ end def block(%{value: %{_type: "block", style: style}} = assigns) do Logger.warn("unknown block style: #{inspect(style)}") ~H"""

    <%= render_slot(@inner_block) %>

    """ end defp marks(%{marks: []} = assigns) do ~H""" <%= render_slot(@inner_block) %> """ end defp marks(%{marks: [_ | _]} = assigns) do assigns = put_shared_props(assigns) ~H""" <.render_with mod={@mod} func={:mark} {mark_props(@value.mark_defs, List.first(@marks))}><.marks marks={remaining_marks(@marks)} {@shared_props}><%= render_slot(@inner_block) %> """ end defp mark_props(mark_defs, mark) do case Enum.find(mark_defs, &(&1._key == mark)) do nil -> %{ mark_key: mark, mark_type: mark, value: nil } %{_type: type} = mark_def -> %{ mark_key: mark, mark_type: type, value: mark_def } end end defp remaining_marks([_first | remaining]), do: remaining @doc false @impl true def mark(%{mark_type: "em"} = assigns) do ~H""" <%= render_slot(@inner_block) %> """ end def mark(%{mark_type: "strong"} = assigns) do ~H""" <%= render_slot(@inner_block) %> """ end def mark(%{mark_type: "link"} = assigns) do ~H""" <%= render_slot(@inner_block) %> """ end def mark(%{mark_type: mark_type} = assigns) do Logger.warn("unknown mark type: #{inspect(mark_type)}") ~H""" <%= render_slot(@inner_block) %> """ end end