defmodule NLdoc.Conversion.Reader.Tiptap do @moduledoc """ Reader for convert_elementing Tiptap elements to Spec Resources. """ use TypedStruct defmodule State do @moduledoc """ Format of state modified during converting. """ typedstruct enforce: true do field :footnotes, [NLdoc.Spec.Footnote.t()], default: [] end end defmodule Context do @moduledoc """ Context for conversion that is only relevant to children and not to be passed back. """ typedstruct enforce: true do field :strict?, boolean() end end @type opt() :: {:strict, boolean()} @type error() :: {:error, term()} @type input(resource) :: {resource, state :: State.t(), ctx :: Context.t()} @type output(value) :: {:ok, {value, state :: State.t()}} | error() @type tiptap_element() :: %{required(:type) => String.t()} @spec convert(doc :: tiptap_element(), opts: [opt()]) :: {:ok, NLdoc.Spec.Document.t()} | error() def convert(doc = %{type: "doc"}, opts \\ []) do state = %State{} ctx = %Context{strict?: Keyword.get(opts, :strict, false)} case convert_element({doc, state, ctx}) do {:ok, {[resource = %NLdoc.Spec.Document{}], state = %State{}}} -> {:ok, reverse(%{resource | footnotes: state.footnotes})} end end @conversions [ {NLdoc.Spec.Document, "doc"}, {NLdoc.Spec.Paragraph, "paragraph"}, {NLdoc.Spec.Heading, "heading"}, {NLdoc.Spec.Table, "table"}, {NLdoc.Spec.TableRow, "tableRow"}, {NLdoc.Spec.TableCell, "tableCell"}, {NLdoc.Spec.TableHeader, "tableHeader"}, {NLdoc.Spec.DefinitionList, "definitionList"}, {NLdoc.Spec.DefinitionTerm, "definitionTerm"}, {NLdoc.Spec.DefinitionDetails, "definitionDetails"}, {NLdoc.Spec.OrderedList, "orderedList"}, {NLdoc.Spec.UnorderedList, "bulletList"}, {NLdoc.Spec.ListItem, "listItem"}, {NLdoc.Spec.Preformatted, "codeBlock"}, {NLdoc.Spec.BlockQuotation, "blockquote"} ] for {module, element_name} <- @conversions do @spec convert_element(input(map())) :: output([unquote(module).t()]) defp convert_element( {element = %{type: unquote(element_name)}, state = %State{}, ctx = %Context{}} ) do with {:ok, {resource = %unquote(module){}, state = %State{}}} <- apply_attributes({%unquote(module){}, state, ctx}, element), {:ok, {children, state = %State{}}} <- convert_children({Map.get(element, :content, []), state, ctx}, &convert_element/1) do {:ok, {[%unquote(module){} = %{resource | children: children}], state}} end end end @spec convert_element(input(map())) :: output([NLdoc.Spec.FootnoteReference.t()]) defp convert_element({element = %{type: "footnote"}, state = %State{}, ctx = %Context{}}) do with {:ok, {resource = %NLdoc.Spec.Footnote{}, state = %State{}}} <- apply_attributes({%NLdoc.Spec.Footnote{}, state, ctx}, element), {:ok, {children, state = %State{}}} <- convert_children({Map.get(element, :content, []), state, ctx}, &convert_element/1) do %NLdoc.Spec.Footnote{} = footnote = %{ resource | children: [ %NLdoc.Spec.Paragraph{ id: Ecto.UUID.generate(), children: children } ] } {:ok, {[%NLdoc.Spec.FootnoteReference{id: Ecto.UUID.generate(), resource_id: footnote.id}], %State{} = %{state | footnotes: [footnote | state.footnotes]}}} end end @spec convert_element(input(map())) :: output([NLdoc.Spec.Image.t()]) defp convert_element({element = %{type: "image"}, state = %State{}, ctx = %Context{}}) do with {:ok, {resource = %NLdoc.Spec.Image{}, state = %State{}}} <- apply_attributes({%NLdoc.Spec.Image{}, state, ctx}, element) do if resource.source do {:ok, {[resource], state}} else {:ok, {[], state}} end end end @spec convert_element(input(map())) :: output([NLdoc.Spec.Text.t()]) @spec convert_element(input(map())) :: output([NLdoc.Spec.Link.t()]) defp convert_element({element = %{type: "text"}, state = %State{}, _ctx = %Context{}}) do marks = Map.get(element, :marks, []) link_mark = Enum.find(marks, &match?(%{type: "link"}, &1)) meta_attrs = marks |> Enum.find(%{}, &match?(%{type: "meta"}, &1)) |> Map.get(:attrs, %{}) resource = if link_mark do link_attrs = Map.get(link_mark, :attrs, %{}) %NLdoc.Spec.Link{ id: meta_attrs |> Map.get(:id) |> uuid_if_nil(), text: Map.get(element, :text, ""), uri: Map.get(link_attrs, :href), purpose: link_attrs |> Map.get(:purpose) |> empty_string_as_nil(), styling: marks_to_styling(marks) } else %NLdoc.Spec.Text{ id: meta_attrs |> Map.get(:id) |> uuid_if_nil(), text: Map.get(element, :text, ""), styling: marks_to_styling(marks) } end {:ok, {[resource], state}} end defp convert_element({_unsupported_resource, state = %State{}, _ctx = %Context{}}), do: {:ok, {[], state}} @spec convert_children(input([child]), convert_fn :: (input(child) -> output([result]))) :: output([result]) when child: var, result: var defp convert_children({children, state = %State{}, ctx = %Context{}}, convert_fn), do: Enum.reduce( children, {:ok, {[], state}}, fn child, {:ok, {contents, state}} -> with {:ok, {content, state}} <- convert_fn.({child, state, ctx}) do {:ok, {content ++ contents, state}} end _, {:error, reason} -> {:error, reason} end ) @attr_conversions [ {NLdoc.Spec.Document, [ id: {:id, :uuid_if_nil}, assets: {:assets, :modify_assets} ]}, {NLdoc.Spec.Paragraph, [ id: {:id, :uuid_if_nil} ]}, {NLdoc.Spec.Heading, [ id: {:id, :uuid_if_nil}, level: {:level, nil} ]}, {NLdoc.Spec.Table, [ id: {:id, :uuid_if_nil} ]}, {NLdoc.Spec.TableRow, [ id: {:id, :uuid_if_nil}, colspan: {:colspan, nil}, rowspan: {:rowspan, nil} ]}, {NLdoc.Spec.TableCell, [ id: {:id, :uuid_if_nil}, colspan: {:colspan, nil}, rowspan: {:rowspan, nil} ]}, {NLdoc.Spec.TableHeader, [ id: {:id, :uuid_if_nil} ]}, {NLdoc.Spec.DefinitionList, [ id: {:id, :uuid_if_nil} ]}, {NLdoc.Spec.DefinitionTerm, [ id: {:id, :uuid_if_nil} ]}, {NLdoc.Spec.DefinitionDetails, [ id: {:id, :uuid_if_nil} ]}, {NLdoc.Spec.OrderedList, [ id: {:id, :uuid_if_nil}, start: {:start, nil} ]}, {NLdoc.Spec.UnorderedList, [ id: {:id, :uuid_if_nil}, style_type: {:style_type, nil} ]}, {NLdoc.Spec.ListItem, [ id: {:id, :uuid_if_nil} ]}, {NLdoc.Spec.Preformatted, [ id: {:id, :uuid_if_nil} ]}, {NLdoc.Spec.BlockQuotation, [ id: {:id, :uuid_if_nil} ]}, {NLdoc.Spec.Image, [ id: {:id, :uuid_if_nil}, decorative: {:decorative, nil}, alternative_text: {:alt, nil}, source: {:assetId, :normalize_asset_source} ]}, {NLdoc.Spec.Footnote, [ id: {:id, :uuid_if_nil} ]} ] for {module, mapping} <- @attr_conversions do attrs_var = Macro.var(:attrs, nil) resource_var = Macro.var(:resource, nil) pipeline_ast = Enum.reduce(mapping, quote(do: unquote(resource_var)), fn {field, {tiptap_attr, modifier}}, acc -> base_val_ast = quote do Map.get(unquote(attrs_var), unquote(tiptap_attr), nil) end val_ast = case modifier do nil -> base_val_ast fun_name when is_atom(fun_name) -> quote do unquote(fun_name)(unquote(base_val_ast)) end end quote do val = unquote(val_ast) if is_nil(val) do unquote(acc) else struct(unquote(acc), %{unquote(field) => val}) end end end) @spec apply_attributes(input(%unquote(module){}), map()) :: output(%unquote(module){}) defp apply_attributes( {resource = %unquote(module){}, state = %State{}, _ctx = %Context{}}, tiptap_element = %{} ) do attrs = Map.get(tiptap_element, :attrs, %{}) {:ok, {unquote(pipeline_ast), state}} end end @styling_mapping [ strikethrough: "strike", code: "code", bold: "bold", italic: "italic", underline: "underline", subscript: "subscript", superscript: "superscript" ] @spec marks_to_styling([term()]) :: [NLdoc.Spec.text_style()] for {spec, tiptap} <- @styling_mapping do defp marks_to_styling([%{type: unquote(tiptap)} | xs]), do: [unquote(spec) | marks_to_styling(xs)] end defp marks_to_styling([_ | xs]), do: marks_to_styling(xs) defp marks_to_styling([]), do: [] @spec uuid_if_nil(nil) :: String.t() defp uuid_if_nil(nil), do: Ecto.UUID.generate() @spec uuid_if_nil(value) :: value when value: term() defp uuid_if_nil(value), do: value @spec normalize_asset_source(value :: String.t()) :: String.t() defp normalize_asset_source(value) when is_binary(value), do: "#" <> value @spec normalize_asset_source(value) :: value when value: term() defp normalize_asset_source(value), do: value @spec empty_string_as_nil(value :: String.t()) :: String.t() | nil @spec empty_string_as_nil(value) :: value when value: term() defp empty_string_as_nil(""), do: nil defp empty_string_as_nil(value), do: value @spec modify_assets(assets :: term()) :: [NLdoc.Spec.Asset.t()] | nil defp modify_assets(assets) when is_map(assets) do Enum.reduce( assets, [], fn {id, base64}, acc -> [NLdoc.Spec.Asset.from_base64(Atom.to_string(id), base64) | acc] end ) end defp modify_assets(_assets), do: nil @spec reverse(content) :: content when content: term() defp reverse(content) when is_list(content), do: content |> Enum.map(&reverse/1) |> Enum.reverse() defp reverse(resource = %{children: children}) when is_list(children), do: Map.put(resource, :children, reverse(children)) defp reverse(other), do: other end