diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..3b4db1a --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,5 @@ +[ + import_deps: [], + inputs: ["*.{ex,exs}", "{config,lib,test}/**/*.{ex,exs}"], + subdirectories: [] +] diff --git a/README.md b/README.md index 4fef7de..c0a8e1c 100644 --- a/README.md +++ b/README.md @@ -2,4 +2,95 @@ Draft is a module that parses a JSON representation of [Draft.js](https://facebook.github.io/draft-js/) `convertToRaw` output and turns it into HTML. -It is very work in progress. +## Usage + +You can `use Draft` in your module, and then extend it with custom +`process_block`, `process_style`, and `process_entity` function signatures + +```elixir + # define your draft module + defmodule MyCustomDraft do + use Draft + + + def process_block(%{"type" => "atomic", + "text" => "", + "key" => _, + "data" => %{"type" => "image", "url" => url}, + "depth" => _, + "entityRanges" => _, + "inlineStyleRanges" => _}, _) do + "" + end + end + + # somewhere else you can pass your custom draft map to `to_html` + input = %{ + "entityMap"=>%{}, + "blocks"=>[ + %{"key"=>"9d21d", + "text"=>"Hello", + "type"=>"header-one", + "depth"=>0, + "inlineStyleRanges"=>[], + "entityRanges"=>[], + "data"=>%{}} + %{"key"=>"d12d9", + "text"=>"", + "type"=>"atomic", + "depth"=>0, + "inlineStyleRanges"=>[], + "entityRanges"=>[], + "data"=>%{ + "type"=>"atomic", + "url"=>"https://uploads.digitalonboarding.com/do_logo_long.png"}}]} + + MyCustomDraft.to_html(input) +``` + +You can pass a third variable for context that your custom processors can hook +into. + +```elixir + # capture the relevant context vars in your custom processors + defmodule MyCustomDraft do + use Draft + + + def process_block(%{"type" => "atomic", + "text" => "", + "key" => _, + "data" => %{"type" => "image", "url" => url}, + "depth" => _, + "entityRanges" => _, + "inlineStyleRanges" => _}, [user: user]) do + "#{user.name}" + end + end + + # somewhere else you can pass any number of vars as the third argument + + user = %{name: "Pablo"} + + input = %{ + "entityMap"=>%{}, + "blocks"=>[ + %{"key"=>"9d21d", + "text"=>"Hello", + "type"=>"header-one", + "depth"=>0, + "inlineStyleRanges"=>[], + "entityRanges"=>[], + "data"=>%{}} + %{"key"=>"d12d9", + "text"=>"", + "type"=>"atomic", + "depth"=>0, + "inlineStyleRanges"=>[], + "entityRanges"=>[], + "data"=>%{ + "type"=>"atomic", + "url"=>"https://uploads.digitalonboarding.com/do_logo_long.png"}}]} + + MyCustomDraft.to_html(input, user: user) +``` diff --git a/lib/draft.ex b/lib/draft.ex index 7fbe389..8520320 100644 --- a/lib/draft.ex +++ b/lib/draft.ex @@ -1,35 +1,67 @@ defmodule Draft do - @moduledoc """ - Provides functions for parsing DraftJS content. - """ - - @doc """ - Parses the given DraftJS input and returns the blocks as a list of - maps. - - ## Examples - iex> draft = ~s({"entityMap":{},"blocks":[{"key":"1","text":"Hello","type":"unstyled","depth":0,"inlineStyleRanges":[],"entityRanges":[],"data":{}}]}) - iex> Draft.blocks draft - [%{"key" => "1", "text" => "Hello", "type" => "unstyled", - "depth" => 0, "inlineStyleRanges" => [], "entityRanges" => [], - "data" => %{}}] - """ - def blocks(input) do - Poison.Parser.parse!(input)["blocks"] - end + defmacro __using__(_) do + quote do + @moduledoc """ + Provides functions for parsing DraftJS content. + """ - @doc """ - Renders the given DraftJS input as html. + @doc """ + Renders the given DraftJS input as html. - ## Examples - iex> draft = ~s({"entityMap":{},"blocks":[{"key":"1","text":"Hello","type":"unstyled","depth":0,"inlineStyleRanges":[],"entityRanges":[],"data":{}}]}) + ## Examples + iex> draft = %{"entityMap"=>%{},"blocks"=>[%{"key"=>"1","text"=>"Hello","type"=>"unstyled","depth"=>0,"inlineStyleRanges"=>[],"entityRanges"=>[],"data"=>%{}}]} iex> Draft.to_html draft "

Hello

" - """ - def to_html(input) do - input - |> blocks - |> Enum.map(&Draft.Block.to_html/1) - |> Enum.join("") + """ + + use Draft.Block + + def to_html(input, context \\ []) do + entity_map = Map.get(input, "entityMap") + + input + |> Map.get("blocks") + |> Enum.reduce([], &group_list_items/2) + |> Enum.map(&process_block(&1, entity_map, context)) + |> Enum.join("") + end + + @doc """ + Groups pertinent block types (i.e. ordered and unordered lists), allowing us to define + `process_block` signatures for both the wrapper component and their children (see + process_block signature for `unordered-list`, it's responsible for rendering its children) + + ## Examples + iex> blocks = [%{"key"=>"1","text"=>"Hello","type"=>"unordered-list-item","depth"=>0,"inlineStyleRanges"=>[],"entityRanges"=>[],"data"=>%{}}] + iex> Enum.reduce(blocks, [], group_list_items()) + [%{"type"=>"unordered-list","data"=>%{"children"=>[%{"key"=>"1","text"=>"Hello","type"=>"unordered-list-item","depth"=>0,"inlineStyleRanges"=>[],"entityRanges"=>[],"data"=>%{}}]}}] + """ + + def group_list_items(block, acc) do + case block["type"] do + type when type in ["unordered-list-item", "ordered-list-item"] -> + list_type = String.replace(block["type"], "-item", "") + + # FIXME: this ignores depth + with {last_item, all_but_last_item} when not is_nil(last_item) <- + List.pop_at(acc, length(acc) - 1), + type when type == list_type <- Map.get(last_item, "type") do + all_but_last_item ++ [add_block_item_to_previous_list(last_item, block)] + else + _ -> acc ++ [%{"type" => list_type, "data" => %{"children" => [block]}}] + end + + _ -> + acc ++ [block] + end + end + + defp add_block_item_to_previous_list(previous_list, block) do + updated_children = previous_list["data"]["children"] ++ [block] + updated_data = Map.put(previous_list["data"], "children", updated_children) + + Map.put(previous_list, "data", updated_data) + end + end end end diff --git a/lib/draft/block.ex b/lib/draft/block.ex index b73be18..aff640a 100644 --- a/lib/draft/block.ex +++ b/lib/draft/block.ex @@ -1,68 +1,75 @@ defmodule Draft.Block do - @moduledoc """ - Converts a single DraftJS block to html. - """ + defmacro __using__(_) do + quote do + @moduledoc """ + Converts a single DraftJS block to html. + """ - @doc """ - Renders the given DraftJS input as html. + use Draft.Ranges - ## Examples - iex> block = %{"key" => "1", "text" => "Hello", "type" => "unstyled", - ...> "depth" => 0, "inlineStyleRanges" => [], "entityRanges" => [], - ...> "data" => %{}} - iex> Draft.Block.to_html block - "

Hello

" - """ - def to_html(block) do - process_block(block) - end + def process_block(%{"type" => "unstyled", "text" => ""}, _, _) do + "
" + end - defp process_block(%{"type" => "unstyled", - "text" => "", - "key" => _, - "data" => _, - "depth" => _, - "entityRanges" => _, - "inlineStyleRanges" => _}) do - "
" - end + def process_block( + %{"type" => "header-" <> header, "text" => text} = block, + entity_map, + context + ) do + tag = header_tags()[header] + "<#{tag}>#{apply_ranges(block, entity_map, context)}" + end - defp process_block(%{"type" => "header-" <> header, - "text" => text, - "key" => _, - "data" => _, - "depth" => _, - "entityRanges" => _, - "inlineStyleRanges" => _}) do - tag = header_tags[header] - "<#{tag}>#{text}" - end + def process_block(%{"type" => "blockquote", "text" => text} = block, entity_map, context) do + "
#{apply_ranges(block, entity_map, context)}
" + end - defp process_block(%{"type" => "blockquote", - "text" => text, - "key" => _, - "data" => _, - "depth" => _, - "entityRanges" => _, - "inlineStyleRanges" => _}) do - "
#{text}
" - end + def process_block(%{"type" => "unstyled", "text" => text} = block, entity_map, context) do + "

#{apply_ranges(block, entity_map, context)}

" + end - defp process_block(%{"type" => "unstyled", - "text" => text, - "key" => _, - "data" => _, - "depth" => _, - "entityRanges" => _, - "inlineStyleRanges" => _}) do - "

#{text}

" - end + def process_block( + %{"type" => "unordered-list", "data" => %{"children" => children}}, + entity_map, + context + ) do + "" + end + + def process_block( + %{"type" => "ordered-list", "data" => %{"children" => children}}, + entity_map, + context + ) do + "
    #{Enum.map(children, &process_block(&1, entity_map, context)) |> Enum.join("")}
" + end + + def process_block( + %{"type" => "ordered-list-item", "text" => text} = block, + entity_map, + context + ) do + "
  • #{apply_ranges(block, entity_map, context)}
  • " + end + + def process_block( + %{"type" => "unordered-list-item", "text" => text} = block, + entity_map, + context + ) do + "
  • #{apply_ranges(block, entity_map, context)}
  • " + end - defp header_tags do - %{ - "one" => "h1", - "two" => "h2", - "three" => "h3" - } + def header_tags do + %{ + "one" => "h1", + "two" => "h2", + "three" => "h3", + "four" => "h4", + "five" => "h5", + "six" => "h6" + } + end + end end end diff --git a/lib/draft/ranges.ex b/lib/draft/ranges.ex new file mode 100644 index 0000000..94a014c --- /dev/null +++ b/lib/draft/ranges.ex @@ -0,0 +1,142 @@ +defmodule Draft.Ranges do + defmacro __using__(_) do + quote do + @moduledoc """ + Provides functions for adding inline style ranges and entity ranges + """ + + def apply_ranges(block, entity_map, context) do + block["inlineStyleRanges"] + |> divvy_style_ranges(block["entityRanges"]) + |> group_style_ranges() + |> Kernel.++(block["entityRanges"]) + |> sort_by_offset_and_length_then_styles(entity_map) + |> DraftTree.build_tree(block["text"]) + |> DraftTree.process_tree(fn text, styles, key -> + cond do + !is_nil(styles) -> + css = + styles + |> Enum.map(fn style -> process_style(style, context) end) + |> Enum.join(" ") + + "#{text}" + + !is_nil(key) -> + process_entity(entity_map |> Map.get(Integer.to_string(key)), text, context) + + true -> + text + end + end) + end + + def process_style("BOLD", _) do + "font-weight: bold;" + end + + def process_style("ITALIC", _) do + "font-style: italic;" + end + + def process_style("UNDERLINE", _) do + "text-decoration: underline;" + end + + # NB: styling will be processed first + # This fixes a bug in Outlook where a `span`'s styling has no effect on the `a` tag. + # So instead of `Google` we want + # `Google` + def process_style_order(range, %{ + "type" => "LINK", + "mutability" => "MUTABLE", + "data" => _data + }) do + !is_nil(range["styles"]) + end + + # NB: styling will be processed last + def process_style_order(range, _entity) do + is_nil(range["styles"]) + end + + def process_entity( + %{"type" => "LINK", "mutability" => "MUTABLE", "data" => %{"url" => url}}, + text, + _ + ) do + "#{text}" + end + + defp group_style_ranges(ranges) do + ranges + |> Enum.group_by(fn range -> {range["offset"], range["length"]} end) + |> Enum.map(fn {{offset, length}, ranges} -> + %{"offset" => offset, "length" => length, "styles" => ranges |> Enum.map(& &1["style"])} + end) + end + + @doc """ + Cuts up multiple potentially overlapping ranges into more mutually exclusive ranges + """ + def divvy_style_ranges(style_ranges, entity_ranges) do + Enum.map(style_ranges, fn style_range -> + ranges_to_points(entity_ranges ++ style_ranges) + |> Enum.filter(fn point -> + point > style_range["offset"] && point < style_range["offset"] + style_range["length"] + end) + |> Enum.reduce([style_range], fn point, acc -> + {already_split, [main]} = Enum.split(acc, length(acc) - 1) + + already_split ++ + [ + %{ + "style" => style_range["style"], + "offset" => main["offset"], + "length" => point - main["offset"] + }, + %{ + "style" => style_range["style"], + "offset" => point, + "length" => main["length"] - (point - main["offset"]) + } + ] + end) + end) + |> List.flatten() + end + + defp sort_by_offset_and_length_then_styles(ranges, entity_map) do + ranges + |> Enum.sort(fn range1, range2 -> + cond do + range1["offset"] != range2["offset"] -> + range1["offset"] < range2["offset"] + + range1["length"] != range2["length"] -> + range1["length"] >= range2["length"] + + true -> + key = + case range2["key"] do + nil -> nil + key -> Integer.to_string(key) + end + + entity = Map.get(entity_map, key) + + process_style_order(range2, entity) + end + end) + end + + defp ranges_to_points(ranges) do + Enum.reduce(ranges, [], fn range, acc -> + acc ++ [range["offset"], range["offset"] + range["length"]] + end) + |> Enum.uniq() + |> Enum.sort() + end + end + end +end diff --git a/lib/draft/tree.ex b/lib/draft/tree.ex new file mode 100644 index 0000000..db36891 --- /dev/null +++ b/lib/draft/tree.ex @@ -0,0 +1,76 @@ +defmodule DraftTree do + defmodule Node do + defstruct offset: 0, length: 0, children: [], styles: [], key: nil, text: "" + end + + def build_tree(ranges, text) do + root_node = %Node{text: text, length: String.length(text), styles: nil} + + Enum.reduce(ranges, root_node, fn range, tree -> insert_node(tree, range, text) end) + end + + defp insert_node(tree, range, text) do + first_included_child = + Enum.find(tree.children, fn child -> + range["offset"] >= child.offset && + range["length"] + range["offset"] <= child.length + child.offset + end) + + case first_included_child do + nil -> + Map.put( + tree, + :children, + tree.children ++ + [ + %Node{ + length: range["length"], + offset: range["offset"], + styles: range["styles"], + key: range["key"], + text: String.slice(text, range["offset"], range["length"]) + } + ] + ) + + child -> + {all_but_last_item, _} = Enum.split(tree.children, length(tree.children) - 1) + + Map.put( + tree, + :children, + all_but_last_item ++ + [ + insert_node(child, range, text) + ] + ) + end + end + + def process_tree(%{children: [], key: key, styles: styles, text: text}, processor) do + processor.(text, styles, key) + end + + def process_tree( + %{ + children: children, + key: key, + styles: styles, + offset: offset, + text: text + }, + processor + ) do + Enum.map(children, fn child -> {child, process_tree(child, processor)} end) + |> Enum.reverse() + |> Enum.reduce(text, fn {child, child_text}, acc -> + {start, rest} = String.split_at(acc, child.offset - offset) + + {_, finish} = + String.split_at(rest, child.offset - offset + child.length - String.length(start)) + + start <> child_text <> finish + end) + |> processor.(styles, key) + end +end diff --git a/mix.exs b/mix.exs index 2213426..0b7d348 100644 --- a/mix.exs +++ b/mix.exs @@ -2,12 +2,14 @@ defmodule Draft.Mixfile do use Mix.Project def project do - [app: :draft, - version: "0.1.0", - elixir: "~> 1.3", - build_embedded: Mix.env == :prod, - start_permanent: Mix.env == :prod, - deps: deps()] + [ + app: :draft, + version: "0.1.0", + elixir: "~> 1.3", + build_embedded: Mix.env() == :prod, + start_permanent: Mix.env() == :prod, + deps: deps() + ] end # Configuration for the OTP application @@ -29,8 +31,7 @@ defmodule Draft.Mixfile do defp deps do [ {:credo, "~> 0.3", only: [:dev, :test]}, - {:ex_doc, "~> 0.14", only: :dev}, - {:poison, "~> 2.0"} + {:ex_doc, "~> 0.14", only: :dev} ] end end diff --git a/mix.lock b/mix.lock index 5540876..bcae6a9 100644 --- a/mix.lock +++ b/mix.lock @@ -1,5 +1,6 @@ -%{"bunt": {:hex, :bunt, "0.1.6", "5d95a6882f73f3b9969fdfd1953798046664e6f77ec4e486e6fafc7caad97c6f", [:mix], []}, +%{ + "bunt": {:hex, :bunt, "0.1.6", "5d95a6882f73f3b9969fdfd1953798046664e6f77ec4e486e6fafc7caad97c6f", [:mix], []}, "credo": {:hex, :credo, "0.5.2", "92e8c9f86e0ffbf9f688595e9f4e936bc96a52e5606d2c19713e9e4d191d5c74", [:mix], [{:bunt, "~> 0.1.6", [hex: :bunt, optional: false]}]}, "earmark": {:hex, :earmark, "1.0.3", "89bdbaf2aca8bbb5c97d8b3b55c5dd0cff517ecc78d417e87f1d0982e514557b", [:mix], []}, "ex_doc": {:hex, :ex_doc, "0.14.5", "c0433c8117e948404d93ca69411dd575ec6be39b47802e81ca8d91017a0cf83c", [:mix], [{:earmark, "~> 1.0", [hex: :earmark, optional: false]}]}, - "poison": {:hex, :poison, "2.2.0", "4763b69a8a77bd77d26f477d196428b741261a761257ff1cf92753a0d4d24a63", [:mix], []}} +} diff --git a/test/custom_entity_test.exs b/test/custom_entity_test.exs new file mode 100644 index 0000000..676ca4b --- /dev/null +++ b/test/custom_entity_test.exs @@ -0,0 +1,159 @@ +defmodule CustomEntityTest do + use ExUnit.Case + use Draft + + def process_entity( + %{ + "type" => "PERSONALIZATION", + "mutability" => "IMMUTABLE", + "data" => %{"value" => value} + }, + _text, + contact: contact + ) do + contact |> Map.get(value) + end + + test "nests entities that potentially replace content inside styles" do + input = %{ + "blocks" => [ + %{ + "key" => "ck6bi", + "data" => %{}, + "text" => "#CONTACT.NAME_FULL", + "type" => "unstyled", + "depth" => 0, + "entityRanges" => [ + %{ + "key" => 0, + "length" => 18, + "offset" => 0 + } + ], + "inlineStyleRanges" => [ + %{ + "style" => "BOLD", + "length" => 18, + "offset" => 0 + } + ] + } + ], + "entityMap" => %{ + "0" => %{ + "data" => %{ + "value" => "name" + }, + "type" => "PERSONALIZATION", + "mutability" => "IMMUTABLE" + } + } + } + + assert to_html(input, contact: %{"name" => "Frodo", "email" => "frodo@middleearth.com"}) == + "

    Frodo

    " + end + + test "ranges adjacent to entities that potentially replace content" do + input = %{ + "blocks" => [ + %{ + "key" => "ck6bi", + "data" => %{}, + "text" => "It's going great #CONTACT.NAME_FULL. Right?", + "type" => "unstyled", + "depth" => 0, + "entityRanges" => [ + %{ + "key" => 0, + "length" => 5, + "offset" => 5 + }, + %{ + "key" => 1, + "length" => 18, + "offset" => 17 + } + ], + "inlineStyleRanges" => [%{"style" => "BOLD", "length" => 5, "offset" => 37}] + } + ], + "entityMap" => %{ + "0" => %{ + "data" => %{ + "url" => "http://google.com" + }, + "type" => "LINK", + "mutability" => "MUTABLE" + }, + "1" => %{ + "data" => %{ + "value" => "name" + }, + "type" => "PERSONALIZATION", + "mutability" => "IMMUTABLE" + } + } + } + + assert to_html(input, contact: %{"name" => "Frodo", "email" => "frodo@middleearth.com"}) == + "

    It's going great Frodo. Right?

    " + end + + test "a random complex combination" do + input = %{ + "blocks" => [ + %{ + "key" => "ck6bi", + "data" => %{}, + "text" => "It's going great #CONTACT.NAME_FULL. Right?", + "type" => "unstyled", + "depth" => 0, + "entityRanges" => [ + %{ + "key" => 0, + "length" => 38, + "offset" => 5 + }, + %{ + "key" => 1, + "length" => 18, + "offset" => 17 + } + ], + "inlineStyleRanges" => [ + %{ + "style" => "BOLD", + "length" => 1, + "offset" => 0 + }, + %{ + "style" => "BOLD", + "length" => 26, + "offset" => 11 + } + ] + } + ], + "entityMap" => %{ + "0" => %{ + "data" => %{ + "url" => "http://google.com" + }, + "type" => "LINK", + "mutability" => "MUTABLE" + }, + "1" => %{ + "data" => %{ + "value" => "name" + }, + "type" => "PERSONALIZATION", + "mutability" => "IMMUTABLE" + } + } + } + + assert to_html(input, contact: %{"name" => "Frodo", "email" => "frodo@middleearth.com"}) == + "

    It's going great Frodo. Right?

    " + end +end diff --git a/test/draft/tree_test.exs b/test/draft/tree_test.exs new file mode 100644 index 0000000..4a61680 --- /dev/null +++ b/test/draft/tree_test.exs @@ -0,0 +1,76 @@ +defmodule TreeTest do + use ExUnit.Case + + test "builds a tree" do + assert DraftTree.build_tree( + [ + %{"length" => 1, "offset" => 0, "styles" => ["BOLD"]}, + %{"key" => 0, "length" => 38, "offset" => 5}, + %{"length" => 6, "offset" => 11, "styles" => ["BOLD"]}, + %{"length" => 18, "offset" => 17, "styles" => ["BOLD"]}, + %{"key" => 1, "length" => 18, "offset" => 17}, + %{"length" => 2, "offset" => 35, "styles" => ["BOLD"]} + ], + "It's going great #CONTACT.NAME_FULL. Right?" + ) == + %DraftTree.Node{ + key: nil, + length: 43, + offset: 0, + styles: nil, + text: "It's going great #CONTACT.NAME_FULL. Right?", + children: [ + %DraftTree.Node{ + key: nil, + length: 1, + offset: 0, + styles: ["BOLD"], + text: "I", + children: [] + }, + %DraftTree.Node{ + key: 0, + length: 38, + offset: 5, + styles: nil, + text: "going great #CONTACT.NAME_FULL. Right?", + children: [ + %DraftTree.Node{ + key: nil, + length: 6, + offset: 11, + styles: ["BOLD"], + text: "great ", + children: [] + }, + %DraftTree.Node{ + key: nil, + length: 18, + offset: 17, + styles: ["BOLD"], + text: "#CONTACT.NAME_FULL", + children: [ + %DraftTree.Node{ + key: 1, + length: 18, + offset: 17, + styles: nil, + text: "#CONTACT.NAME_FULL", + children: [] + } + ] + }, + %DraftTree.Node{ + key: nil, + length: 2, + offset: 35, + styles: ["BOLD"], + text: ". ", + children: [] + } + ] + } + ] + } + end +end diff --git a/test/draft_test.exs b/test/draft_test.exs index 9fd1359..093fa60 100644 --- a/test/draft_test.exs +++ b/test/draft_test.exs @@ -1,40 +1,513 @@ defmodule DraftTest do use ExUnit.Case - doctest Draft + use Draft test "generate a

    " do - input = ~s({"entityMap":{},"blocks":[{"key":"9d21d","text":"Hello","type":"unstyled","depth":0,"inlineStyleRanges":[],"entityRanges":[],"data":{}}]}) + input = %{ + "entityMap" => %{}, + "blocks" => [ + %{ + "key" => "9d21d", + "text" => "Hello", + "type" => "unstyled", + "depth" => 0, + "inlineStyleRanges" => [], + "entityRanges" => [], + "data" => %{} + } + ] + } + output = "

    Hello

    " - assert Draft.to_html(input) == output + assert to_html(input) == output end test "generate a

    " do - input = ~s({"entityMap":{},"blocks":[{"key":"9d21d","text":"Hello","type":"header-one","depth":0,"inlineStyleRanges":[],"entityRanges":[],"data":{}}]}) + input = %{ + "entityMap" => %{}, + "blocks" => [ + %{ + "key" => "9d21d", + "text" => "Hello", + "type" => "header-one", + "depth" => 0, + "inlineStyleRanges" => [], + "entityRanges" => [], + "data" => %{} + } + ] + } + output = "

    Hello

    " - assert Draft.to_html(input) == output + assert to_html(input) == output end test "generate a

    " do - input = ~s({"entityMap":{},"blocks":[{"key":"9d21d","text":"Hello","type":"header-two","depth":0,"inlineStyleRanges":[],"entityRanges":[],"data":{}}]}) + input = %{ + "entityMap" => %{}, + "blocks" => [ + %{ + "key" => "9d21d", + "text" => "Hello", + "type" => "header-two", + "depth" => 0, + "inlineStyleRanges" => [], + "entityRanges" => [], + "data" => %{} + } + ] + } + output = "

    Hello

    " - assert Draft.to_html(input) == output + assert to_html(input) == output end test "generate a

    " do - input = ~s({"entityMap":{},"blocks":[{"key":"9d21d","text":"Hello","type":"header-three","depth":0,"inlineStyleRanges":[],"entityRanges":[],"data":{}}]}) + input = %{ + "entityMap" => %{}, + "blocks" => [ + %{ + "key" => "9d21d", + "text" => "Hello", + "type" => "header-three", + "depth" => 0, + "inlineStyleRanges" => [], + "entityRanges" => [], + "data" => %{} + } + ] + } + output = "

    Hello

    " - assert Draft.to_html(input) == output + assert to_html(input) == output + end + + test "generate a

    " do + input = %{ + "entityMap" => %{}, + "blocks" => [ + %{ + "key" => "9d21d", + "text" => "Hello", + "type" => "header-four", + "depth" => 0, + "inlineStyleRanges" => [], + "entityRanges" => [], + "data" => %{} + } + ] + } + + output = "

    Hello

    " + assert to_html(input) == output + end + + test "generate a
    " do + input = %{ + "entityMap" => %{}, + "blocks" => [ + %{ + "key" => "9d21d", + "text" => "Hello", + "type" => "header-five", + "depth" => 0, + "inlineStyleRanges" => [], + "entityRanges" => [], + "data" => %{} + } + ] + } + + output = "
    Hello
    " + assert to_html(input) == output + end + + test "generate a
    " do + input = %{ + "entityMap" => %{}, + "blocks" => [ + %{ + "key" => "9d21d", + "text" => "Hello", + "type" => "header-six", + "depth" => 0, + "inlineStyleRanges" => [], + "entityRanges" => [], + "data" => %{} + } + ] + } + + output = "
    Hello
    " + assert to_html(input) == output end test "generate a
    " do - input = ~s({"entityMap":{},"blocks":[{"key":"9d21d","text":"Hello","type":"blockquote","depth":0,"inlineStyleRanges":[],"entityRanges":[],"data":{}}]}) + input = %{ + "entityMap" => %{}, + "blocks" => [ + %{ + "key" => "9d21d", + "text" => "Hello", + "type" => "blockquote", + "depth" => 0, + "inlineStyleRanges" => [], + "entityRanges" => [], + "data" => %{} + } + ] + } + output = "
    Hello
    " - assert Draft.to_html(input) == output + assert to_html(input) == output end test "generate a
    " do - input = ~s({"entityMap":{},"blocks":[{"key":"9d21d","text":"","type":"unstyled","depth":0,"inlineStyleRanges":[],"entityRanges":[],"data":{}}]}) + input = %{ + "entityMap" => %{}, + "blocks" => [ + %{ + "key" => "9d21d", + "text" => "", + "type" => "unstyled", + "depth" => 0, + "inlineStyleRanges" => [], + "entityRanges" => [], + "data" => %{} + } + ] + } + output = "
    " - assert Draft.to_html(input) == output + assert to_html(input) == output + end + + test "wraps single inline style" do + input = %{ + "entityMap" => %{}, + "blocks" => [ + %{ + "text" => "Hello", + "inlineStyleRanges" => [%{"style" => "BOLD", "offset" => 2, "length" => 2}], + "type" => "unstyled", + "depth" => 0, + "entityRanges" => [], + "data" => %{}, + "key" => "9d21d" + } + ] + } + + output = "

    Hello

    " + assert to_html(input) == output + end + + test "wraps multiple inline styles" do + input = %{ + "entityMap" => %{}, + "blocks" => [ + %{ + "text" => "Hello World!", + "inlineStyleRanges" => [ + %{"style" => "ITALIC", "offset" => 8, "length" => 3}, + %{"style" => "BOLD", "offset" => 2, "length" => 2}, + %{"style" => "UNDERLINE", "offset" => 4, "length" => 4} + ], + "type" => "unstyled", + "depth" => 0, + "entityRanges" => [], + "data" => %{}, + "key" => "9d21d" + } + ] + } + + output = + "

    Hello World!

    " + + assert to_html(input) == output + end + + test "wraps nested inline styles" do + input = %{ + "entityMap" => %{}, + "blocks" => [ + %{ + "text" => "Hello World!", + "inlineStyleRanges" => [ + %{"style" => "ITALIC", "offset" => 2, "length" => 5}, + %{"style" => "BOLD", "offset" => 2, "length" => 2} + ], + "type" => "unstyled", + "depth" => 0, + "entityRanges" => [], + "data" => %{}, + "key" => "9d21d" + } + ] + } + + output = + "

    Hello World!

    " + + assert to_html(input) == output + end + + test "wraps overlapping inline styles" do + input = %{ + "entityMap" => %{}, + "blocks" => [ + %{ + "text" => "Hello World!", + "inlineStyleRanges" => [ + %{"style" => "ITALIC", "offset" => 2, "length" => 5}, + %{"style" => "BOLD", "offset" => 4, "length" => 5} + ], + "type" => "unstyled", + "depth" => 0, + "entityRanges" => [], + "data" => %{}, + "key" => "9d21d" + } + ] + } + + output = + "

    Hello World!

    " + + assert to_html(input) == output + end + + test "wraps anchor entities" do + input = %{ + "entityMap" => %{ + "0" => %{ + "type" => "LINK", + "mutability" => "MUTABLE", + "data" => %{"url" => "http://google.com"} + } + }, + "blocks" => [ + %{ + "text" => "Hello World!", + "inlineStyleRanges" => [], + "type" => "unstyled", + "depth" => 0, + "entityRanges" => [ + %{"offset" => 2, "length" => 3, "key" => 0} + ], + "data" => %{}, + "key" => "9d21d" + } + ] + } + + output = "

    Hello World!

    " + assert to_html(input) == output + end + + test "wraps overlapping entities and inline styles" do + input = %{ + "entityMap" => %{ + "0" => %{ + "type" => "LINK", + "mutability" => "MUTABLE", + "data" => %{"url" => "http://google.com"} + } + }, + "blocks" => [ + %{ + "text" => "Hello World!", + "inlineStyleRanges" => [ + %{"style" => "ITALIC", "offset" => 0, "length" => 4}, + %{"style" => "BOLD", "offset" => 4, "length" => 4} + ], + "entityRanges" => [ + %{"offset" => 2, "length" => 3, "key" => 0} + ], + "type" => "unstyled", + "depth" => 0, + "data" => %{}, + "key" => "9d21d" + } + ] + } + + output = + "

    Hello World!

    " + + assert to_html(input) == output + end + + test "anchor entities text is wrapped by an inline style span tag" do + input = %{ + "blocks" => [ + %{ + "depth" => 0, + "entityRanges" => [ + %{ + "key" => 0, + "offset" => 0, + "length" => 6 + } + ], + "inlineStyleRanges" => [ + %{ + "style" => "BOLD", + "offset" => 0, + "length" => 6 + } + ], + "data" => %{}, + "text" => "Google", + "key" => "c2jk5", + "type" => "unstyled" + } + ], + "entityMap" => %{ + "0" => %{ + "type" => "LINK", + "data" => %{ + "url" => "https=>\/\/www.google.com", + "target" => "_blank" + }, + "mutability" => "MUTABLE" + } + } + } + + output = + "

    //www.google.com\">Google

    " + + assert to_html(input) == output + end + + test "wraps ordered lists in
      " do + input = %{ + "entityMap" => %{}, + "blocks" => [ + %{ + "key" => "9d21d", + "text" => "one", + "type" => "ordered-list-item", + "depth" => 0, + "inlineStyleRanges" => [], + "entityRanges" => [], + "data" => %{} + } + ] + } + + output = "
      1. one
      " + assert to_html(input) == output + end + + test "wraps unordered lists in