diff --git a/.iex.exs b/.iex.exs new file mode 100644 index 000000000..88e4c63a5 --- /dev/null +++ b/.iex.exs @@ -0,0 +1,2 @@ +# see: https://github.com/blackode/elixir-tips#loading-project-module-aliases-iexexs +alias Helper.Converter.EditorToHTML diff --git a/Makefile b/Makefile index 2d629524c..425a392f9 100644 --- a/Makefile +++ b/Makefile @@ -171,9 +171,8 @@ test.watch.wip: # test.watch not work now, see: https://github.com/lpil/mix-test.watch/issues/116 # mix test.watch --only wip --stale test.watch.wip2: - mix test --listen-on-stdin --stale --trace --only wip2 + mix test --listen-on-stdin --stale --only wip2 # mix test.watch --only wip2 - mix test.watch --only wip2 test.watch.bug: mix test.watch --only bug test.report: diff --git a/lib/helper/converter/assets/delimiter_icons.ex b/lib/helper/converter/assets/delimiter_icons.ex index 8b6b44ad3..485934230 100644 --- a/lib/helper/converter/assets/delimiter_icons.ex +++ b/lib/helper/converter/assets/delimiter_icons.ex @@ -1,4 +1,4 @@ -defmodule Helper.Converter.EditorToHtml.Assets.DelimiterIcons do +defmodule Helper.Converter.EditorToHTML.Assets.DelimiterIcons do @moduledoc """ svg icons for delimiter block NOTE: those svg should be sync with frontend svg diff --git a/lib/helper/converter/editor_to_html/header.ex b/lib/helper/converter/editor_to_html/header.ex new file mode 100644 index 000000000..b021f1f7e --- /dev/null +++ b/lib/helper/converter/editor_to_html/header.ex @@ -0,0 +1,80 @@ +defmodule Helper.Converter.EditorToHTML.Header do + @moduledoc """ + parse editor.js's header block + + see https://editorjs.io/ + """ + + # @behaviour Helper.Converter.EditorToHTML.Parser + + defmacro __using__(_opts) do + quote do + alias Helper.Metric + + @clazz Metric.Article.class_names(:html) + + defp parse_block(%{ + "type" => "header", + "data" => + %{ + "text" => text, + "level" => level, + "eyebrowTitle" => eyebrow_title, + "footerTitle" => footer_title + } = data + }) do + """ +
+
#{eyebrow_title}
+ #{text} +
#{footer_title}
+
+ """ + end + + defp parse_block(%{ + "type" => "header", + "data" => + %{ + "text" => text, + "level" => level, + "eyebrowTitle" => eyebrow_title + } = data + }) do + """ +
+
#{eyebrow_title}
+ #{text} +
+ """ + end + + defp parse_block(%{ + "type" => "header", + "data" => + %{ + "text" => text, + "level" => level, + "footerTitle" => footer_title + } = data + }) do + """ +
+ #{text} +
#{footer_title}
+
+ """ + end + + defp parse_block(%{ + "type" => "header", + "data" => %{ + "text" => text, + "level" => level + } + }) do + "#{text}" + end + end + end +end diff --git a/lib/helper/converter/editor_to_html.ex b/lib/helper/converter/editor_to_html/index.ex similarity index 63% rename from lib/helper/converter/editor_to_html.ex rename to lib/helper/converter/editor_to_html/index.ex index a4321272b..30dbf60c2 100644 --- a/lib/helper/converter/editor_to_html.ex +++ b/lib/helper/converter/editor_to_html/index.ex @@ -1,31 +1,44 @@ -defmodule Helper.Converter.EditorToHtml do +# defmodule Helper.Converter.EditorToHTML.Parser do +# @moduledoc false + +# # TODO: map should be editor_block +# @callback parse_block(editor_json :: Map.t()) :: String.t() +# end + +defmodule Helper.Converter.EditorToHTML do @moduledoc """ parse editor.js's json data to raw html and sanitize it see https://editorjs.io/ """ - alias Helper.Converter.HtmlSanitizer - alias Helper.Converter.EditorToHtml.Assets - alias Helper.Utils - alias Assets.{DelimiterIcons} + use Helper.Converter.EditorToHTML.Header + use Helper.Converter.EditorToHTML.Paragraph + use Helper.Converter.EditorToHTML.List + + alias Helper.Converter.EditorToHTML.Validator + alias Helper.Converter.{EditorToHTML, HtmlSanitizer} + alias Helper.{Metric, Utils} + + alias EditorToHTML.Assets.{DelimiterIcons} - @html_class_prefix "cps-viewer" + @clazz Metric.Article.class_names(:html) @spec to_html(binary | maybe_improper_list) :: false | {:ok, <<_::64, _::_*8>>} def to_html(string) when is_binary(string) do with {:ok, parsed} = string_to_json(string), - true <- valid_editor_data?(parsed) do + {:ok, _} <- Validator.is_valid(parsed) do content = Enum.reduce(parsed["blocks"], "", fn block, acc -> clean_html = block |> parse_block |> HtmlSanitizer.sanitize() acc <> clean_html end) - {:ok, "
#{content}
"} + {:ok, "
#{content}
"} end end + @doc "used for markdown ast to editor" def to_html(editor_blocks) when is_list(editor_blocks) do content = Enum.reduce(editor_blocks, "", fn block, acc -> @@ -33,29 +46,13 @@ defmodule Helper.Converter.EditorToHtml do acc <> clean_html end) - {:ok, "
#{content}
"} - end - - # IO.inspect(data, label: "parse header") - defp parse_block(%{"type" => "header", "data" => data}) do - text = get_in(data, ["text"]) - level = get_in(data, ["level"]) - - "#{text}" + {:ok, "
#{content}
"} end - # IO.inspect(data, label: "parse paragraph") - defp parse_block(%{"type" => "paragraph", "data" => data}) do - text = get_in(data, ["text"]) - - "

#{text}

" - end - - # IO.inspect(data, label: "parse image") defp parse_block(%{"type" => "image", "data" => data}) do url = get_in(data, ["file", "url"]) - "
" + "
" # |> IO.inspect(label: "iamge ret") end @@ -77,7 +74,6 @@ defmodule Helper.Converter.EditorToHtml do "
    #{content}
" end - # IO.inspect(items, label: "checklist items") # TODO: add item class defp parse_block(%{"type" => "checklist", "data" => %{"items" => items}}) do content = @@ -94,7 +90,7 @@ defmodule Helper.Converter.EditorToHtml do end end) - "
#{content}
" + "
#{content}
" # |> IO.inspect(label: "jjj") end @@ -102,7 +98,7 @@ defmodule Helper.Converter.EditorToHtml do svg_icon = DelimiterIcons.svg(type) # TODO: left-wing, righ-wing staff - {:skip_sanitize, "
#{svg_icon}
"} + {:skip_sanitize, "
#{svg_icon}
"} end # IO.inspect(data, label: "parse linkTool") @@ -110,7 +106,7 @@ defmodule Helper.Converter.EditorToHtml do defp parse_block(%{"type" => "linkTool", "data" => data}) do link = get_in(data, ["link"]) - "" + "" # |> IO.inspect(label: "linkTool ret") end @@ -118,7 +114,7 @@ defmodule Helper.Converter.EditorToHtml do defp parse_block(%{"type" => "quote", "data" => data}) do text = get_in(data, ["text"]) - "
#{text}
" + "
#{text}
" # |> IO.inspect(label: "quote ret") end @@ -132,18 +128,8 @@ defmodule Helper.Converter.EditorToHtml do end defp parse_block(_block) do - # IO.puts("[unknow block]") - "[unknow block]" + "
[unknow block]
" end def string_to_json(string), do: Jason.decode(string) - - defp valid_editor_data?(map) when is_map(map) do - Map.has_key?(map, "time") and - Map.has_key?(map, "version") and - Map.has_key?(map, "blocks") and - is_list(map["blocks"]) and - is_binary(map["version"]) and - is_integer(map["time"]) - end end diff --git a/lib/helper/converter/editor_to_html/list.ex b/lib/helper/converter/editor_to_html/list.ex new file mode 100644 index 000000000..f83c82d22 --- /dev/null +++ b/lib/helper/converter/editor_to_html/list.ex @@ -0,0 +1,196 @@ +defmodule Helper.Converter.EditorToHTML.List do + @moduledoc """ + parse editor.js's list-like block (include checklist order/unorder list) + + see https://editorjs.io/ + """ + + # @behaviour Helper.Converter.EditorToHTML.Parser + + defmacro __using__(_opts) do + quote do + alias Helper.Metric + + @clazz Metric.Article.class_names(:html) + + defp parse_block(%{ + "type" => "list", + "data" => + %{ + "mode" => "checklist", + "items" => [ + %{ + "checked" => checked, + "hideLabel" => hide_label, + "indent" => indent, + "label" => label, + "labelType" => label_type, + "text" => text + } + ] + } = data + }) do + """ +
+ hello list +
+ """ + + #
+ #
#{eyebrow_title}
+ # #{text} + #
#{footer_title}
+ #
+ end + + defp parse_block(%{ + "type" => "list", + "data" => + %{ + "text" => text, + "level" => level, + "eyebrowTitle" => eyebrow_title + } = data + }) do + """ +
+
#{eyebrow_title}
+ #{text} +
+ """ + end + + defp parse_block(%{ + "type" => "list", + "data" => + %{ + "text" => text, + "level" => level, + "footerTitle" => footer_title + } = data + }) do + """ +
+ #{text} +
#{footer_title}
+
+ """ + end + + defp parse_block(%{ + "type" => "list", + "data" => %{ + "text" => text, + "level" => level + } + }) do + "#{text}" + end + end + end +end + +# { +# type: "list", +# data: { +# type: "checklist", +# items: [ +# { +# checked: true, +# hideLabel: false, +# indent: 0, +# label: '标签', +# labelType: 'default', +# text: "content 1.", +# }, +# { +# checked: false, +# hideLabel: false, +# indent: 1, +# label: '完成', +# labelType: 'green', +# text: "content 1.1", +# }, +# { +# checked: false, +# hideLabel: false, +# indent: 1, +# label: '未完成', +# labelType: 'red', +# text: "content 1.2", +# }, +# { +# checked: false, +# hideLabel: false, +# indent: 0, +# label: '完成', +# labelType: 'green', +# text: "content 2.", +# }, +# { +# checked: false, +# hideLabel: false, +# indent: 1, +# label: '标签', +# labelType: 'default', +# text: "content 2.1", +# }, +# { +# checked: false, +# hideLabel: false, +# indent: 2, +# label: '标签', +# labelType: 'default', +# text: "content 2.1.1", +# }, +# { +# checked: false, +# hideLabel: false, +# indent: 2, +# label: '未完成', +# labelType: 'red', +# text: "content 2.1.2", +# }, +# { +# checked: false, +# hideLabel: false, +# indent: 3, +# label: '未完成', +# labelType: 'red', +# text: "content 2.1.2.1", +# }, +# { +# checked: false, +# hideLabel: false, +# indent: 2, +# label: '完成', +# labelType: 'green', +# text: "content 2.1.3", +# }, +# { +# checked: false, +# hideLabel: false, +# indent: 3, +# label: '标签', +# labelType: 'default', +# text: "content 2.1.3.1", +# }, +# { +# checked: false, +# hideLabel: false, +# indent: 3, +# label: '完成', +# labelType: 'green', +# text: "content 2.1.3.2", +# }, +# { +# checked: false, +# hideLabel: false, +# indent: 0, +# label: '未完成', +# labelType: 'red', +# text: "content 3.", +# }, +# ] +# }, +# } diff --git a/lib/helper/converter/editor_to_html/paragraph.ex b/lib/helper/converter/editor_to_html/paragraph.ex new file mode 100644 index 000000000..f363d0c57 --- /dev/null +++ b/lib/helper/converter/editor_to_html/paragraph.ex @@ -0,0 +1,17 @@ +defmodule Helper.Converter.EditorToHTML.Paragraph do + @moduledoc """ + parse editor.js's paragraph block + + see https://editorjs.io/ + """ + defmacro __using__(_opts) do + quote do + # alias Helper.Metric + # @clazz Metric.Article.class_names(:html) + + defp parse_block(%{"type" => "paragraph", "data" => %{"text" => text}}) do + "

#{text}

" + end + end + end +end diff --git a/lib/helper/converter/editor_to_html/validator/editor_schema.ex b/lib/helper/converter/editor_to_html/validator/editor_schema.ex new file mode 100644 index 000000000..6c72745df --- /dev/null +++ b/lib/helper/converter/editor_to_html/validator/editor_schema.ex @@ -0,0 +1,48 @@ +defmodule Helper.Converter.EditorToHTML.Validator.EditorSchema do + @moduledoc false + + # header + @valid_header_level [1, 2, 3] + + # list + @valid_list_mode ["checklist", "order_list", "unorder_list"] + @valid_list_label_type ["success", "done", "todo"] + @valid_list_indent [0, 1, 2, 3, 4] + + def get("editor") do + %{ + "time" => [:number], + "version" => [:string], + "blocks" => [:list] + } + end + + def get("header") do + %{ + "text" => [:string], + "level" => [enum: @valid_header_level], + "eyebrowTitle" => [:string, required: false], + "footerTitle" => [:string, required: false] + } + end + + def get("paragraph"), do: %{"text" => [:string]} + + def get("list") do + [ + parent: %{"mode" => [enum: @valid_list_mode], "items" => [:list]}, + item: %{ + "checked" => [:boolean], + "hideLabel" => [:boolean], + "label" => [:string], + "labelType" => [enum: @valid_list_label_type], + "indent" => [enum: @valid_list_indent], + "text" => [:string] + } + ] + end + + def get(_) do + %{} + end +end diff --git a/lib/helper/converter/editor_to_html/validator/index.ex b/lib/helper/converter/editor_to_html/validator/index.ex new file mode 100644 index 000000000..6775fb937 --- /dev/null +++ b/lib/helper/converter/editor_to_html/validator/index.ex @@ -0,0 +1,110 @@ +defmodule Helper.Converter.EditorToHTML.Validator do + @moduledoc false + + alias Helper.{Converter, Validator} + + alias Validator.Schema + alias Converter.EditorToHTML.Validator.EditorSchema + + # blocks with no children items + @simple_blocks ["header", "paragraph"] + # blocks with "mode" and "items" fields + @complex_blocks ["list"] + + def is_valid(data) when is_map(data) do + with {:ok, _} <- validate_editor_fmt(data), + blocks <- Map.get(data, "blocks") do + try do + validate_blocks(blocks) + rescue + e in MatchError -> + format_parse_error(e) + + e in RuntimeError -> + format_parse_error(e) + + _ -> + format_parse_error() + end + end + end + + defp validate_editor_fmt(data) do + try do + validate_with("editor", EditorSchema.get("editor"), data) + rescue + e in MatchError -> + format_parse_error(e) + + _ -> + format_parse_error() + end + end + + defp validate_blocks([]), do: {:ok, :pass} + + defp validate_blocks(blocks) do + Enum.each(blocks, fn block -> + # if error happened, will be rescued + {:ok, _} = validate_block(block) + end) + + {:ok, :pass} + end + + # validate block which have no nested items + defp validate_block(%{"type" => type, "data" => data}) when type in @simple_blocks do + validate_with(type, EditorSchema.get(type), data) + end + + # validate block which has mode and items + defp validate_block(%{"type" => type, "data" => data}) + when type in @complex_blocks do + [parent: parent_schema, item: item_schema] = EditorSchema.get(type) + validate_with(type, parent_schema, item_schema, data) + end + + defp validate_block(%{"type" => type}), do: raise("undown #{type} block") + + defp validate_block(e), do: raise("undown block: #{e}") + + # validate with given schema + defp validate_with(block, schema, data) do + case Schema.cast(schema, data) do + {:error, errors} -> + {:error, message} = format_parse_error(block, errors) + raise %MatchError{term: {:error, message}} + + _ -> + {:ok, :pass} + end + end + + defp validate_with(block, parent_schema, item_schema, data) do + with {:ok, _} <- validate_with(block, parent_schema, data), + %{"mode" => mode, "items" => items} <- data do + Enum.each(items, fn item -> + validate_with("#{block}(#{mode})", item_schema, item) + end) + + {:ok, :pass} + end + end + + defp format_parse_error(type, error_list) when is_list(error_list) do + {:error, + Enum.map(error_list, fn error -> + Map.merge(error, %{block: type}) + end)} + end + + defp format_parse_error(%MatchError{term: {:error, error}}) do + {:error, error} + end + + defp format_parse_error(%{message: message}) do + {:error, message} + end + + defp format_parse_error(), do: {:error, "undown validate error"} +end diff --git a/lib/helper/metric/article.ex b/lib/helper/metric/article.ex new file mode 100644 index 000000000..c1f265d5b --- /dev/null +++ b/lib/helper/metric/article.ex @@ -0,0 +1,30 @@ +defmodule Helper.Metric.Article do + @moduledoc """ + html article class names parsed from editor.js's json data + + currently use https://editorjs.io/ as rich-text editor + # NOTE: DONOT CHANGE ONCE SET, OTHERWISE IT WILL CAUSE INCOMPATIBILITY ISSUE + """ + + @doc """ + get all the class names of the parsed editor.js's html parts + """ + def class_names(:html) do + %{ + # root wrapper + viewer: "article-viewer-wrapper", + unknow_block: "unknow-block", + invalid_block: "invalid-block", + # header + header: %{ + wrapper: "header-wrapper", + eyebrow_title: "eyebrow-title", + footer_title: "footer-title" + }, + # list + list: %{ + wrapper: "list-wrapper" + } + } + end +end diff --git a/lib/helper/PublicIpPlug.ex b/lib/helper/public_ip_plug.ex similarity index 100% rename from lib/helper/PublicIpPlug.ex rename to lib/helper/public_ip_plug.ex diff --git a/lib/helper/utils.ex b/lib/helper/utils.ex index f52e5c339..13b64b4ff 100644 --- a/lib/helper/utils.ex +++ b/lib/helper/utils.ex @@ -94,6 +94,24 @@ defmodule Helper.Utils do map |> Enum.reduce(%{}, fn {key, val}, acc -> Map.put(acc, to_string(key), val) end) end + @doc """ + see https://stackoverflow.com/a/61559842/4050784 + adjust it for map keys from atom to string + """ + def keys_to_atoms(json) when is_map(json) do + Map.new(json, &reduce_keys_to_atoms/1) + end + + def keys_to_atoms(string) when is_binary(string), do: string + + def reduce_keys_to_atoms({key, val}) when is_map(val), + do: {String.to_existing_atom(key), keys_to_atoms(val)} + + def reduce_keys_to_atoms({key, val}) when is_list(val), + do: {String.to_existing_atom(key), Enum.map(val, &keys_to_atoms(&1))} + + def reduce_keys_to_atoms({key, val}), do: {String.to_existing_atom(key), val} + @doc """ see https://stackoverflow.com/a/61559842/4050784 adjust it for map keys from atom to string diff --git a/lib/helper/validator/schema.ex b/lib/helper/validator/schema.ex new file mode 100644 index 000000000..bef9f2969 --- /dev/null +++ b/lib/helper/validator/schema.ex @@ -0,0 +1,78 @@ +defmodule Helper.Validator.Schema do + @moduledoc """ + validate json data by given schema, mostly used in editorjs validator + + currently support boolean / string / number / enum + """ + + use Helper.Validator.Schema.Matchers, [:string, :number, :list, :boolean] + + @doc """ + cast data by given schema + + ## example + schema = %{ + checked: [:boolean], + hideLabel: [:boolean], + label: [:string], + labelType: [:string], + indent: [enum: [0, 1, 2, 3, 4]], + text: [:string] + } + + data = %{checked: true, label: "done"} + Schema.cast(schema, data) + """ + def cast(schema, data) do + errors_info = cast_errors(schema, data) + + case errors_info do + [] -> {:ok, :pass} + _ -> {:error, errors_info} + end + end + + defp cast_errors(schema, data) do + schema_fields = Map.keys(schema) + + Enum.reduce(schema_fields, [], fn field, acc -> + value = get_in(data, [field]) + field_schema = get_in(schema, [field]) + + case match(field, value, field_schema) do + {:error, error} -> + acc ++ [error] + + _ -> + acc + end + end) + end + + # enum + defp match(field, nil, enum: _, required: false), do: done(field, nil) + + defp match(field, value, enum: enum, required: false) do + match(field, value, enum: enum) + end + + defp match(field, value, enum: enum) do + case value in enum do + true -> + {:ok, value} + + false -> + {:error, + %{ + field: field, + message: "should be: #{enum |> Enum.join(" | ")}" + }} + end + end + + defp done(field, value), do: {:ok, %{field: field, value: value}} + + defp error(field, value, schema) do + {:error, %{field: field |> to_string, value: value, message: "should be: #{schema}"}} + end +end diff --git a/lib/helper/validator/schema_matchers.ex b/lib/helper/validator/schema_matchers.ex new file mode 100644 index 000000000..bcc62c774 --- /dev/null +++ b/lib/helper/validator/schema_matchers.ex @@ -0,0 +1,29 @@ +defmodule Helper.Validator.Schema.Matchers do + @moduledoc """ + matchers for basic type, support required option + """ + + defmacro __using__(types) do + # can not use Enum.each here, see https://elixirforum.com/t/define-multiple-modules-in-macro-only-last-one-gets-created/1654/4 + for type <- types do + guard_name = if type == :string, do: "is_binary", else: "is_#{to_string(type)}" + + quote do + defp match(field, nil, [unquote(type), required: false]), do: done(field, nil) + + defp match(field, value, [unquote(type), required: false]) + when unquote(:"#{guard_name}")(value) do + done(field, value) + end + + defp match(field, value, [unquote(type)]) when unquote(:"#{guard_name}")(value), + do: done(field, value) + + defp match(field, value, [unquote(type), required: false]), + do: error(field, value, unquote(type)) + + defp match(field, value, [unquote(type)]), do: error(field, value, unquote(type)) + end + end + end +end diff --git a/mix.lock b/mix.lock index 6bd0343af..91c426ea0 100644 --- a/mix.lock +++ b/mix.lock @@ -28,6 +28,7 @@ "ecto": {:hex, :ecto, "3.5.6", "29c77e999e471921c7ce7347732bab7bfa3e24c587640a36f17e0744d1474b8e", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3ae1f3eaecc3e72eeb65ed43239b292bb1eaf335c7e6cea3a7fc27aadb6e93e7"}, "ecto_sql": {:hex, :ecto_sql, "3.5.4", "a9e292c40bd79fff88885f95f1ecd7b2516e09aa99c7dd0201aa84c54d2358e4", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.5.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.3.0 or ~> 0.4.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1fff1a28a898d7bbef263f1f3ea425b04ba9f33816d843238c84eff883347343"}, "elixir_make": {:hex, :elixir_make, "0.4.0", "992f38fabe705bb45821a728f20914c554b276838433349d4f2341f7a687cddf", [], [], "hexpm"}, + "elixir_uuid": {:hex, :elixir_uuid, "1.2.1", "dce506597acb7e6b0daeaff52ff6a9043f5919a4c3315abb4143f0b00378c097", [], [], "hexpm", "f7eba2ea6c3555cea09706492716b0d87397b88946e6380898c2889d68585752"}, "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, "eternal": {:hex, :eternal, "1.2.1", "d5b6b2499ba876c57be2581b5b999ee9bdf861c647401066d3eeed111d096bc4", [:mix], [], "hexpm", "b14f1dc204321429479c569cfbe8fb287541184ed040956c8862cb7a677b8406"}, "ex_unit_notifier": {:hex, :ex_unit_notifier, "0.1.4", "36a2dcab829f506e01bf17816590680dd1474407926d43e64c1263e627c364b8", [:mix], [], "hexpm", "fddf5054dd5fd2f809e837b749570baa5c9798e11d0163921baec49b7d5762f2"}, @@ -45,6 +46,7 @@ "idna": {:hex, :idna, "6.0.0", "689c46cbcdf3524c44d5f3dde8001f364cd7608a99556d8fbd8239a5798d4c10", [:rebar3], [{:unicode_util_compat, "0.4.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "4bdd305eb64e18b0273864920695cb18d7a2021f31a11b9c5fbcd9a253f936e2"}, "inch_ex": {:hex, :inch_ex, "2.0.0", "24268a9284a1751f2ceda569cd978e1fa394c977c45c331bb52a405de544f4de", [:mix], [{:bunt, "~> 0.2", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "96d0ec5ecac8cf63142d02f16b7ab7152cf0f0f1a185a80161b758383c9399a8"}, "jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fdf843bca858203ae1de16da2ee206f53416bbda5dc8c9e78f43243de4bc3afe"}, + "joi": {:hex, :joi, "0.1.4", "cdef436c62ddcaa596f7612d1f903c7be32050248dfc9692b6e7255a5dddd926", [:mix], [{:elixir_uuid, "~> 1.2", [hex: :elixir_uuid, repo: "hexpm", optional: false]}], "hexpm", "5ef5f35e3b716a60dfbf312ccef27b4a4543b01d9c4a59ddadca6fd688c09db6"}, "jose": {:hex, :jose, "1.11.1", "59da64010c69aad6cde2f5b9248b896b84472e99bd18f246085b7b9fe435dcdb", [:mix, :rebar3], [], "hexpm", "078f6c9fb3cd2f4cfafc972c814261a7d1e8d2b3685c0a76eb87e158efff1ac5"}, "jumper": {:hex, :jumper, "1.0.1", "3c00542ef1a83532b72269fab9f0f0c82bf23a35e27d278bfd9ed0865cecabff", [:mix], [], "hexpm", "318c59078ac220e966d27af3646026db9b5a5e6703cb2aa3e26bcfaba65b7433"}, "libring": {:hex, :libring, "1.4.0", "41246ba2f3fbc76b3971f6bce83119dfec1eee17e977a48d8a9cfaaf58c2a8d6", [:mix], [], "hexpm", "1feaf05ee886815ad047cad7ede17d6910710986148ae09cf73eee2989717b81"}, diff --git a/test/helper/converter/editor_to_html_test.exs b/test/helper/converter/editor_to_html_test.exs deleted file mode 100644 index 730357bdc..000000000 --- a/test/helper/converter/editor_to_html_test.exs +++ /dev/null @@ -1,219 +0,0 @@ -defmodule GroupherServer.Test.Helper.Converter.EditorToHtml do - @moduledoc false - - use GroupherServerWeb.ConnCase, async: true - - alias Helper.Converter.EditorToHtml, as: Parser - - @real_editor_data ~S({ - "time" : 1567250876713, - "blocks" : [ - { - "type" : "header", - "data" : { - "text" : "Editor.js", - "level" : 2 - } - }, - { - "type" : "paragraph", - "data" : { - "text" : "Hey. Meet the new Editor. On this page you can see it in action — try to edit this text." - } - }, - { - "type" : "header", - "data" : { - "text" : "Key features", - "level" : 3 - } - }, - { - "type" : "list", - "data" : { - "style" : "unordered", - "items" : [ - "It is a block-styled editor", - "It returns clean data output in JSON", - "Designed to be extendable and pluggable with a simple API" - ] - } - }, - { - "type" : "header", - "data" : { - "text" : "Key features", - "level" : 3 - } - }, - { - "type" : "list", - "data" : { - "style" : "ordered", - "items" : [ - "It is a block-styled editor", - "It returns clean data output in JSON", - "Designed to be extendable and pluggable with a simple API" - ] - } - }, - { - "type" : "header", - "data" : { - "text" : "What does it mean «block-styled editor»", - "level" : 3 - } - }, - { - "type" : "checklist", - "data" : { - "items" : [ - { - "text" : "This is a block-styled editor", - "checked" : true - }, - { - "text" : "Clean output data", - "checked" : false - }, - { - "text" : "Simple and powerful API", - "checked" : true - } - ] - } - }, - { - "type" : "paragraph", - "data" : { - "text" : "Workspace in classic editors is made of a single contenteditable element, used to create different HTML markups. Editor.js workspace consists of separate Blocks: paragraphs, headings, images, lists, quotes, etc. Each of them is an independent contenteditable element (or more complex structure\) provided by Plugin and united by Editor's Core." - } - }, - { - "type" : "paragraph", - "data" : { - "text" : "There are dozens of ready-to-use Blocks and the simple API for creation any Block you need. For example, you can implement Blocks for Tweets, Instagram posts, surveys and polls, CTA-buttons and even games." - } - }, - { - "type" : "header", - "data" : { - "text" : "What does it mean clean data output", - "level" : 3 - } - }, - { - "type" : "paragraph", - "data" : { - "text" : "Classic WYSIWYG-editors produce raw HTML-markup with both content data and content appearance. On the contrary, Editor.js outputs JSON object with data of each Block. You can see an example below" - } - }, - { - "type" : "paragraph", - "data" : { - "text" : "Given data can be used as you want: render with HTML for Web clients, render natively for mobile apps, create markup for Facebook Instant Articles or Google AMP, generate an audio version and so on." - } - }, - { - "type" : "paragraph", - "data" : { - "text" : "Clean data is useful to sanitize, validate and process on the backend." - } - }, - { - "type" : "delimiter", - "data" : {} - }, - { - "type" : "paragraph", - "data" : { - "text" : "We have been working on this project more than three years. Several large media projects help us to test and debug the Editor, to make it's core more stable. At the same time we significantly improved the API. Now, it can be used to create any plugin for any task. Hope you enjoy. 😏" - } - }, - { - "type" : "image", - "data" : { - "file" : { - "url" : "https://codex.so/upload/redactor_images/o_e48549d1855c7fc1807308dd14990126.jpg" - }, - "caption" : "", - "withBorder" : true, - "stretched" : false, - "withBackground" : false - } - }, - { - "type" : "linkTool", - "data" : { - "link" : "https://www.github.com", - "meta" : { - "url" : "https://www.github.com", - "domain" : "www.github.com", - "title" : "Build software better, together", - "description" : "GitHub is where people build software. More than 40 million people use GitHub to discover, fork, and contribute to over 100 million projects.", - "image" : { - "url" : "https://github.githubassets.com/images/modules/open_graph/github-logo.png" - } - } - } - }, - { - "type" : "quote", - "data" : { - "text" : "quote demo text", - "caption" : "desc?", - "alignment" : "left" - } - }, - { - "type" : "delimiter", - "data" : { - "type" : "pen" - } - }, - { - "type" : "code", - "data" : { - "lang" : "js", - "text" : "" - } - } - ], - "version" : "2.15.0" - }) - - describe "[basic convert]" do - test "basic string_json should work" do - string = ~S({"time":1566184478687,"blocks":[{}],"version":"2.15.0"}) - {:ok, converted} = Parser.string_to_json(string) - - assert converted["time"] == 1_566_184_478_687 - assert converted["version"] == "2.15.0" - end - - test "invalid string data should get error" do - string = ~S({"time":1566184478687,"blocks":[{}],"version":}) - assert {:error, converted} = Parser.string_to_json(string) - end - - test "real-world editor.js data should work" do - {:ok, converted} = Parser.string_to_json(@real_editor_data) - - assert not Enum.empty?(converted["blocks"]) - assert converted["blocks"] |> is_list - assert converted["version"] |> is_binary - assert converted["time"] |> is_integer - end - end - - describe "[block convert]" do - test "code block should avoid potential xss script attack" do - {:ok, converted} = Parser.to_html(@real_editor_data) - - safe_script = - "
<script>evil scripts</script>
" - - assert converted |> String.contains?(safe_script) - end - end -end diff --git a/test/helper/converter/editor_to_html_test/header_test.exs b/test/helper/converter/editor_to_html_test/header_test.exs new file mode 100644 index 000000000..c77e15cad --- /dev/null +++ b/test/helper/converter/editor_to_html_test/header_test.exs @@ -0,0 +1,181 @@ +defmodule GroupherServer.Test.Helper.Converter.EditorToHTML.Header do + @moduledoc false + + use GroupherServerWeb.ConnCase, async: true + + alias Helper.Metric + alias Helper.Converter.EditorToHTML, as: Parser + + @clazz Metric.Article.class_names(:html) + + describe "[header block unit]" do + @editor_json %{ + "time" => 1_567_250_876_713, + "blocks" => [ + %{ + "type" => "header", + "data" => %{ + "text" => "header content", + "level" => 1 + } + }, + %{ + "type" => "header", + "data" => %{ + "text" => "header content", + "level" => 2 + } + }, + %{ + "type" => "header", + "data" => %{ + "text" => "header content", + "level" => 3 + } + } + ], + "version" => "2.15.0" + } + @tag :wip + test "header parse should work" do + {:ok, editor_string} = Jason.encode(@editor_json) + {:ok, converted} = Parser.to_html(editor_string) + + assert converted == + "

header content

header content

header content

" + end + + @editor_json %{ + "time" => 1_567_250_876_713, + "blocks" => [ + %{ + "type" => "header", + "data" => %{ + "text" => "header content", + "level" => 1, + "eyebrowTitle" => "eyebrow title content", + "footerTitle" => "footer title content" + } + } + ], + "version" => "2.15.0" + } + @tag :wip + test "full header parse should work" do + {:ok, editor_string} = Jason.encode(@editor_json) + {:ok, converted} = Parser.to_html(editor_string) + + assert converted == + "
\n
eyebrow title content
\n

header content

\n
footer title content
\n
\n
" + end + + @editor_json %{ + "time" => 1_567_250_876_713, + "version" => "2.15.0" + } + @tag :wip + test "optional field should valid properly" do + json = + Map.merge(@editor_json, %{ + "blocks" => [ + %{ + "type" => "header", + "data" => %{ + "text" => "header content", + "level" => 1, + "eyebrowTitle" => "eyebrow title content" + } + } + ] + }) + + {:ok, editor_string} = Jason.encode(json) + {:ok, converted} = Parser.to_html(editor_string) + + assert converted == + "
\n
eyebrow title content
\n

header content

\n
\n
" + + json = + Map.merge(@editor_json, %{ + "blocks" => [ + %{ + "type" => "header", + "data" => %{ + "text" => "header content", + "level" => 1, + "footerTitle" => "footer title content" + } + } + ] + }) + + {:ok, editor_string} = Jason.encode(json) + {:ok, converted} = Parser.to_html(editor_string) + + assert converted == + "
\n

header content

\n
footer title content
\n
\n
" + end + + @tag :wip + test "wrong header format data should have invalid hint" do + json = + Map.merge(@editor_json, %{ + "blocks" => [ + %{ + "type" => "header", + "data" => %{ + "text" => "header content", + "level" => 1, + "eyebrowTitle" => [], + "footerTitle" => true + } + } + ] + }) + + {:ok, editor_string} = Jason.encode(json) + {:error, error} = Parser.to_html(editor_string) + + assert error == + [ + %{ + block: "header", + field: "eyebrowTitle", + message: "should be: string", + value: [] + }, + %{ + block: "header", + field: "footerTitle", + message: "should be: string", + value: true + } + ] + + json = + Map.merge(@editor_json, %{ + "blocks" => [ + %{ + "type" => "header", + "data" => %{ + "text" => "header content", + "level" => [] + } + } + ] + }) + + {:ok, editor_string} = Jason.encode(json) + {:error, error} = Parser.to_html(editor_string) + assert error == [%{block: "header", field: "level", message: "should be: 1 | 2 | 3"}] + end + end +end diff --git a/test/helper/converter/editor_to_html_test/index_test.exs b/test/helper/converter/editor_to_html_test/index_test.exs new file mode 100644 index 000000000..83b8016ef --- /dev/null +++ b/test/helper/converter/editor_to_html_test/index_test.exs @@ -0,0 +1,161 @@ +defmodule GroupherServer.Test.Helper.Converter.EditorToHTML do + @moduledoc false + + use GroupherServerWeb.ConnCase, async: true + + alias Helper.Metric + alias Helper.Converter.EditorToHTML, as: Parser + + # alias Helper.Metric + # @clazz Metric.Article.class_names(:html) + + # "hello Editor.js workspace. is an element <script>alert("hello")</script>" + + # "text" : "" + @clazz Metric.Article.class_names(:html) + + @real_editor_data ~S({ + "time" : 1567250876713, + "blocks" : [ + { + "type" : "paragraph", + "data" : { + "text": "content" + } + } + ], + "version" : "2.15.0" + }) + + describe "[basic convert]" do + test "basic string_json parse should work" do + string = ~S({"time":1566184478687,"blocks":[{}],"version":"2.15.0"}) + {:ok, converted} = Parser.string_to_json(string) + + assert converted["version"] == "2.15.0" + end + + @editor_json %{ + "time" => 1_567_250_876_713, + "blocks" => [], + "version" => "2.15.0" + } + @tag :wip + test "valid editorjs json fmt should work" do + {:ok, editor_string} = Jason.encode(@editor_json) + + assert {:ok, _} = Parser.to_html(editor_string) + end + + @tag :wip + test "invalid editorjs json fmt should raise error" do + editor_json = %{ + "invalid_time" => 1_567_250_876_713, + "blocks" => [], + "version" => "2.15.0" + } + + {:ok, editor_string} = Jason.encode(editor_json) + {:error, error} = Parser.to_html(editor_string) + + assert error == [ + %{block: "editor", field: "time", message: "should be: number", value: nil} + ] + + editor_json = %{ + "time" => "1_567_250_876_713", + "blocks" => [], + "version" => "2.15.0" + } + + {:ok, editor_string} = Jason.encode(editor_json) + {:error, error} = Parser.to_html(editor_string) + + assert error == [ + %{ + block: "editor", + field: "time", + message: "should be: number", + value: "1_567_250_876_713" + } + ] + + editor_json = %{ + "time" => 1_567_250_876_713, + # invalid blocks type, should be list + "blocks" => "blocks", + "version" => "2.15.0" + } + + {:ok, editor_string} = Jason.encode(editor_json) + {:error, error} = Parser.to_html(editor_string) + + assert error == [ + %{block: "editor", field: "blocks", message: "should be: list", value: "blocks"} + ] + + editor_json = %{ + "time" => 1_567_250_876_713, + "blocks" => [1, 2, 3], + "version" => "2.15.0" + } + + {:ok, editor_string} = Jason.encode(editor_json) + {:error, error} = Parser.to_html(editor_string) + + assert error == "undown block: 1" + end + + test "real-world editor.js data should work" do + {:ok, converted} = Parser.string_to_json(@real_editor_data) + + assert not Enum.empty?(converted["blocks"]) + assert converted["blocks"] |> is_list + assert converted["version"] |> is_binary + assert converted["time"] |> is_integer + end + end + + describe "[secure issues]" do + @tag :wip + test "code block should avoid potential xss script attack" do + editor_json = %{ + "time" => 1_567_250_876_713, + "blocks" => [ + %{ + "type" => "paragraph", + "data" => %{ + "text" => "" + } + } + ], + "version" => "2.15.0" + } + + {:ok, editor_string} = Jason.encode(editor_json) + {:ok, converted} = Parser.to_html(editor_string) + + assert converted == + "

evel script

" + + editor_json = %{ + "time" => 1_567_250_876_713, + "blocks" => [ + %{ + "type" => "paragraph", + "data" => %{ + "text" => "Editor.js is an element <script>evel script</script>" + } + } + ], + "version" => "2.15.0" + } + + {:ok, editor_string} = Jason.encode(editor_json) + {:ok, converted} = Parser.to_html(editor_string) + + assert converted == + "

Editor.js is an element <script>evel script</script>

" + end + end +end diff --git a/test/helper/converter/editor_to_html_test/list_test.exs b/test/helper/converter/editor_to_html_test/list_test.exs new file mode 100644 index 000000000..8c7fbda27 --- /dev/null +++ b/test/helper/converter/editor_to_html_test/list_test.exs @@ -0,0 +1,147 @@ +defmodule GroupherServer.Test.Helper.Converter.EditorToHTML.List do + @moduledoc false + + use GroupherServerWeb.ConnCase, async: true + + alias Helper.Metric + alias Helper.Converter.EditorToHTML, as: Parser + + # @clazz Metric.Article.class_names(:html) + + describe "[list block unit]" do + @editor_json %{ + "time" => 1_567_250_876_713, + "blocks" => [ + %{ + "type" => "list", + "data" => %{ + "mode" => "checklist", + "items" => [ + %{ + "checked" => true, + "hideLabel" => false, + "indent" => 0, + "label" => "label", + "labelType" => "success", + "text" => "list item" + } + ] + } + } + ], + "version" => "2.15.0" + } + @tag :wip + test "valid list parse should work" do + {:ok, editor_string} = Jason.encode(@editor_json) + # assert {:ok, converted} = Parser.to_html(editor_string) + {:ok, converted} = Parser.to_html(editor_string) + IO.inspect(converted, label: "list converted") + end + + @editor_json %{ + "time" => 1_567_250_876_713, + "blocks" => [ + %{ + "type" => "list", + "data" => %{ + "invalid-mode" => "", + "items" => [] + } + } + ], + "version" => "2.15.0" + } + @tag :wip + test "invalid list mode parse should raise error message" do + {:ok, editor_string} = Jason.encode(@editor_json) + {:error, err_msg} = Parser.to_html(editor_string) + + assert err_msg == [ + %{ + block: "list", + field: "mode", + message: "should be: checklist | order_list | unorder_list" + } + ] + end + + @editor_json %{ + "time" => 1_567_250_876_713, + "blocks" => [ + %{ + "type" => "list", + "data" => %{ + "mode" => "checklist", + "items" => [ + %{ + "checked" => true, + "hideLabel" => "invalid", + "indent" => 5, + "label" => "label", + "labelType" => "success", + "text" => "list item" + } + ] + } + } + ], + "version" => "2.15.0" + } + @tag :wip + test "invalid list data parse should raise error message" do + {:ok, editor_string} = Jason.encode(@editor_json) + {:error, err_msg} = Parser.to_html(editor_string) + + assert err_msg == [ + %{ + block: "list(checklist)", + field: "hideLabel", + message: "should be: boolean", + value: "invalid" + }, + %{ + block: "list(checklist)", + field: "indent", + message: "should be: 0 | 1 | 2 | 3 | 4" + } + ] + end + + @editor_json %{ + "time" => 1_567_250_876_713, + "blocks" => [ + %{ + "type" => "list", + "data" => %{ + "mode" => "checklist", + "items" => [ + %{ + "checked" => true, + "hideLabel" => true, + "indent" => 10, + "label" => "label", + "labelType" => "success", + "text" => "list item" + } + ] + } + } + ], + "version" => "2.15.0" + } + @tag :wip + test "invalid indent field should get error" do + {:ok, editor_string} = Jason.encode(@editor_json) + {:error, error} = Parser.to_html(editor_string) + + assert error === [ + %{ + block: "list(checklist)", + field: "indent", + message: "should be: 0 | 1 | 2 | 3 | 4" + } + ] + end + end +end diff --git a/test/helper/converter/editor_to_html_test/paragraph_test.exs b/test/helper/converter/editor_to_html_test/paragraph_test.exs new file mode 100644 index 000000000..9b570719f --- /dev/null +++ b/test/helper/converter/editor_to_html_test/paragraph_test.exs @@ -0,0 +1,54 @@ +defmodule GroupherServer.Test.Helper.Converter.EditorToHTML.Paragraph do + @moduledoc false + + use GroupherServerWeb.ConnCase, async: true + + alias Helper.Metric + alias Helper.Converter.EditorToHTML, as: Parser + + @clazz Metric.Article.class_names(:html) + + describe "[paragraph block]" do + @editor_json %{ + "time" => 1_567_250_876_713, + "blocks" => [ + %{ + "type" => "paragraph", + "data" => %{ + "text" => "paragraph content" + } + } + ], + "version" => "2.15.0" + } + @tag :wip + test "paragraph parse should work" do + {:ok, editor_string} = Jason.encode(@editor_json) + {:ok, converted} = Parser.to_html(editor_string) + + assert converted == "

paragraph content

" + end + + @editor_json %{ + "time" => 1_567_250_876_713, + "blocks" => [ + %{ + "type" => "paragraph", + "data" => %{ + "text" => [] + } + } + ], + "version" => "2.15.0" + } + @tag :wip + test "invalid paragraph should have invalid hint" do + {:ok, editor_string} = Jason.encode(@editor_json) + {:error, error} = Parser.to_html(editor_string) + + assert error == [ + %{block: "paragraph", field: "text", message: "should be: string", value: []} + ] + end + end +end diff --git a/test/helper/converter/md_to_editor_test.exs b/test/helper/converter/md_to_editor_test.exs index d46ff6b93..cfd3c2b9f 100644 --- a/test/helper/converter/md_to_editor_test.exs +++ b/test/helper/converter/md_to_editor_test.exs @@ -4,12 +4,14 @@ defmodule GroupherServer.Test.Helper.Converter.MdToEditor do """ use GroupherServerWeb.ConnCase, async: true + alias Helper.Metric alias Helper.Converter.MdToEditor, as: Converter - alias Helper.Converter.EditorToHtml + alias Helper.Converter.EditorToHTML + + @clazz Metric.Article.class_names(:html) # alias Helper.Converter.HtmlSanitizer, as: Sanitizer describe "[basic md test]" do - @tag :wip test "basic markdown ast parser should work" do markdown = """ # header one @@ -229,7 +231,6 @@ defmodule GroupherServer.Test.Helper.Converter.MdToEditor do ] end - @tag :wip test "complex ast parser should work" do markdown = """ @@ -246,10 +247,10 @@ defmodule GroupherServer.Test.Helper.Converter.MdToEditor do editor_blocks = Converter.parse(markdown) - {:ok, html} = EditorToHtml.to_html(editor_blocks) + {:ok, html} = EditorToHTML.to_html(editor_blocks) assert html == - "

hello

this is a basic markdown text

delete me

italic me

My in-line-code-content is best

" + "

hello

this is a basic markdown text

delete me

italic me

My in-line-code-content is best

" end end end diff --git a/test/helper/converter/mention_parser_test.exs b/test/helper/converter/mention_parser_test.exs index f03d9e4fe..979be5db9 100644 --- a/test/helper/converter/mention_parser_test.exs +++ b/test/helper/converter/mention_parser_test.exs @@ -13,21 +13,18 @@ defmodule GroupherServer.Test.Helper.Converter.MentionParser do this is a #test #message with #a few #test tags from +me @you2 and @中文我 xxx@email.com """ - @tag :wip test "parse should return an empty for blank input" do ret = MentionParser.parse("", :mentions) assert ret == [] end - @tag :wip test "mention should parsed in list" do ret = MentionParser.parse(@test_message, :mentions) assert ret == ["@you", "@you2"] end - @tag :wip test "email should not be parsed" do ret = MentionParser.parse(@test_message, :mentions) diff --git a/test/helper/utils_test.exs b/test/helper/utils_test.exs index b7e784929..642b8c1a4 100644 --- a/test/helper/utils_test.exs +++ b/test/helper/utils_test.exs @@ -4,7 +4,6 @@ defmodule GroupherServer.Test.Helper.UtilsTest do alias Helper.Utils describe "map keys to string" do - @tag :wip test "atom keys should covert to string keys on nested map" do atom_map = %{ data: %{ @@ -24,6 +23,73 @@ defmodule GroupherServer.Test.Helper.UtilsTest do end end + describe "map keys to atom" do + test "string keys should covert to atom keys on nested map" do + atom_map = %{ + string_array: [ + "line 1", + "line 2", + "line 3" + ], + blocks: [ + %{ + data: %{ + items: [ + %{ + checked: true, + hideLabel: true, + indent: 0, + label: "label", + labelType: "success", + text: "list item" + } + ], + mode: "checklist" + }, + type: "list" + } + ] + } + + # atoms dynamically and atoms are not + # garbage-collected. Therefore, string should not be an untrusted value, such as + # input received from a socket or during a web request. Consider using + # to_existing_atom/1 instead + # keys_to_atoms is using to_existing_atom under the hook + + _ = :hideLabel + _ = :labelType + + string_map = %{ + "string_array" => [ + "line 1", + "line 2", + "line 3" + ], + "blocks" => [ + %{ + "data" => %{ + "items" => [ + %{ + "checked" => true, + "hideLabel" => true, + "indent" => 0, + "label" => "label", + "labelType" => "success", + "text" => "list item" + } + ], + "mode" => "checklist" + }, + "type" => "list" + } + ] + } + + assert Utils.keys_to_atoms(string_map) == atom_map + end + end + describe "[deep merge]" do test 'one level of maps without conflict' do result = Utils.deep_merge(%{a: 1}, %{b: 2})