Current section

Files

Jump to
nldoc_conversion_reader_tiptap lib nldoc conversion reader tiptap.ex
Raw

lib/nldoc/conversion/reader/tiptap.ex

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(:assets, [NLdoc.Spec.Asset.t()])
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{assets: []}
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)}
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.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
{:ok, {[resource], state}}
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: Map.get(link_attrs, :purpose),
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}
]}
]
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 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