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
+ "
"
+ 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 + "#{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 + "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":"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 = + "" + + 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 = "
" + assert to_html(input) == output + end + + test "wraps unordered lists in
- one
" do + input = %{ + "entityMap" => %{}, + "blocks" => [ + %{ + "key" => "9d21d", + "text" => "one", + "type" => "unordered-list-item", + "depth" => 0, + "inlineStyleRanges" => [], + "entityRanges" => [], + "data" => %{} + } + ] + } + + output = "
" + assert to_html(input) == output + end + + test "wraps multiple unordered list items in the same
- one
" do + input = %{ + "entityMap" => %{}, + "blocks" => [ + %{ + "key" => "9d21d", + "text" => "one", + "type" => "unordered-list-item", + "depth" => 0, + "inlineStyleRanges" => [], + "entityRanges" => [], + "data" => %{} + }, + %{ + "key" => "9d21e", + "text" => "two", + "type" => "unordered-list-item", + "depth" => 0, + "inlineStyleRanges" => [], + "entityRanges" => [], + "data" => %{} + } + ] + } + + output = + "
" + + assert to_html(input) == output + end + + test "wraps multiple complex lists" do + input = %{ + "entityMap" => %{}, + "blocks" => [ + %{ + "key" => "9d21c", + "text" => "one", + "type" => "unordered-list-item", + "depth" => 0, + "inlineStyleRanges" => [], + "entityRanges" => [], + "data" => %{} + }, + %{ + "key" => "9d21e", + "text" => "whoops", + "type" => "ordered-list-item", + "depth" => 0, + "inlineStyleRanges" => [], + "entityRanges" => [], + "data" => %{} + }, + %{ + "key" => "9d21d", + "text" => "two", + "type" => "unordered-list-item", + "depth" => 0, + "inlineStyleRanges" => [], + "entityRanges" => [], + "data" => %{} + }, + %{ + "key" => "9d21f", + "text" => "Hello", + "type" => "unstyled", + "depth" => 0, + "inlineStyleRanges" => [], + "entityRanges" => [], + "data" => %{} + }, + %{ + "key" => "9d21g", + "text" => "and another", + "type" => "ordered-list-item", + "depth" => 0, + "inlineStyleRanges" => [], + "entityRanges" => [], + "data" => %{} + } + ] + } + + output = + "
- one
- two
- one
- whoops
- two
Hello
" + + assert to_html(input) == output end end
- and another