diff --git a/lib/beacon/jason.ex b/lib/beacon/jason.ex new file mode 100644 index 00000000..7cd859ef --- /dev/null +++ b/lib/beacon/jason.ex @@ -0,0 +1,3 @@ +defimpl Jason.Encoder, for: Tuple do + defdelegate encode(value, opts), to: Beacon.Template.HEEx.JSONEncoder, as: :encode_eex_block +end diff --git a/lib/beacon/template/heex/heex_decoder.ex b/lib/beacon/template/heex/heex_decoder.ex index 63bcc808..5f3f30ad 100644 --- a/lib/beacon/template/heex/heex_decoder.ex +++ b/lib/beacon/template/heex/heex_decoder.ex @@ -55,10 +55,15 @@ defmodule Beacon.Template.HEEx.HEExDecoder do ["<%!--", content, "--%>"] end - defp transform_node(%{"tag" => "eex_block", "blocks" => blocks, "arg" => arg}) do + defp transform_node(%{"tag" => "eex_block", "arg" => arg, "blocks" => blocks}) do ["<%=", arg, " %>", Enum.map(blocks, &transform_block/1)] end + # eex_block with a `for` comprehension + defp transform_node(%{"tag" => "eex_block", "arg" => arg, "ast" => ast}) do + ["<%= ", arg, " %>", decode_comprehension_ast(ast)] + end + defp transform_node(%{"tag" => tag, "attrs" => %{"self_close" => true} = attrs, "content" => []}) do attrs = Map.delete(attrs, "self_close") ["<", tag, " ", transform_attrs(attrs), "/>"] @@ -129,4 +134,25 @@ defmodule Beacon.Template.HEEx.HEExDecoder do defp reconstruct_attr({name, {:expr, content, _}, _}) do [name, "=", ?{, content, ?}] end + + defp decode_comprehension_ast(ast) do + ast + |> Jason.decode!() + |> List.flatten() + |> Enum.reduce([], fn node, acc -> decode_comprehension_ast_node(node, acc) end) + |> Enum.reverse() + end + + defp decode_comprehension_ast_node(%{"type" => "text", "content" => [content | _]}, acc) do + [content | acc] + end + + defp decode_comprehension_ast_node(%{"type" => "eex", "content" => [expr, %{"opt" => ~c"="}]}, acc) do + [["<%= ", expr, " %>"] | acc] + end + + # TODO: improve eex_block closing detection (maybe augment it in JSONEncoder) + defp decode_comprehension_ast_node("end", acc), do: ["<% end %>" | acc] + + defp decode_comprehension_ast_node(node, acc) when is_binary(node), do: [node | acc] end diff --git a/lib/beacon/template/heex/json_encoder.ex b/lib/beacon/template/heex/json_encoder.ex index c5a9b5a7..4591da5c 100644 --- a/lib/beacon/template/heex/json_encoder.ex +++ b/lib/beacon/template/heex/json_encoder.ex @@ -44,7 +44,6 @@ defmodule Beacon.Template.HEEx.JSONEncoder do Note that: * `rendered_html` key is optional - * Comprehensions are not supported ## Example @@ -91,16 +90,7 @@ defmodule Beacon.Template.HEEx.JSONEncoder do end rescue exception -> - message = """ - failed to encode the HEEx template - - Got: - - #{Exception.message(exception)} - - """ - - reraise Beacon.ParserError, [message: message], __STACKTRACE__ + {:error, Exception.message(exception)} end defp encode_tokens(ast, site, assigns) do @@ -129,7 +119,7 @@ defmodule Beacon.Template.HEEx.JSONEncoder do defp transform([], acc, _site, _assigns), do: acc - # Strips blank text nodes and insignificant whitespace before or after text. + # strips out blank text nodes and insignificant whitespace before or after text. defp transform_entry({:text, text, _}, _site, _assigns) do cond do :binary.first(text) in ~c"\n" or :binary.last(text) in ~c"\n" -> @@ -153,12 +143,24 @@ defmodule Beacon.Template.HEEx.JSONEncoder do } end - defp transform_entry({:eex_block, arg, content}, site, assigns) do - %{ - "tag" => "eex_block", - "arg" => arg, - "blocks" => Enum.map(content, fn block -> transform_block(block, site, assigns) end) - } + defp transform_entry({:eex_block, arg, content} = entry, site, assigns) do + arg = String.trim(arg) + + # TODO: improve for comprehensions detection + if String.starts_with?(arg, "for ") do + %{ + "tag" => "eex_block", + "arg" => arg, + "rendered_html" => render_comprehension_block(site, assigns, entry), + "ast" => encode_comprehension_block(entry) + } + else + %{ + "tag" => "eex_block", + "arg" => arg, + "blocks" => Enum.map(content, fn block -> transform_block(block, site, assigns) end) + } + end end defp transform_entry({:eex_comment, text}, _site, _assigns) do @@ -241,4 +243,96 @@ defmodule Beacon.Template.HEEx.JSONEncoder do "content" => encode_tokens(content, site, assigns) } end + + defp encode_comprehension_block({:eex_block, _arg, content}) do + case Jason.encode(content) do + {:ok, json} -> json + error -> error + end + end + + defp render_comprehension_block(site, assigns, {:eex_block, arg, nodes}) do + arg = ["<%= ", arg, " %>", "\n"] + + template = + Enum.reduce(nodes, [arg], fn node, acc -> + [[extract_node_text(node), " \n "] | acc] + end) + |> Enum.reverse() + |> List.to_string() + + Beacon.Template.HEEx.render(site, template, assigns) + end + + defp extract_node_text({nodes, "end"} = value) when is_list(nodes) do + value + |> Tuple.to_list() + |> Enum.reduce([], fn node, acc -> [extract_node_text(node) | acc] end) + |> Enum.reverse() + end + + defp extract_node_text(value) when is_list(value) do + value + |> Enum.reduce([], fn node, acc -> [extract_node_text(node) | acc] end) + |> Enum.reverse() + end + + defp extract_node_text("end" = _value), do: ["<% end %>"] + + defp extract_node_text(value) when is_binary(value), do: value + + defp extract_node_text({:text, text, _}), do: text + + defp extract_node_text({:html_comment, children}), do: [extract_node_text(children), "\n"] + + # TODO: eex comments are stripped out of rendered html by the heex engine + defp extract_node_text({:eex_comment, _content}), do: [] + + defp extract_node_text({:eex, expr, %{opt: ~c"="}}), do: ["<%= ", expr, " %>"] + + defp extract_node_text({:eex, expr, _}), do: ["<% ", expr, " %>"] + + defp extract_node_text({:eex_block, expr, children}), do: ["<%= ", expr, " %>", extract_node_text(children)] + + defp extract_node_text({:tag_self_close, tag, attrs}) do + attrs = + Enum.reduce(attrs, [], fn attr, acc -> + [extract_node_attr(attr) | acc] + end) + + [?<, tag, " ", attrs, "/>"] + end + + defp extract_node_text({:tag_block, tag, _, children, _}) do + [?<, tag, ?>, extract_node_text(children), ""] + end + + defp extract_node_attr({attr, {:string, text, _}, _}), do: [attr, ?=, ?", text, ?", " "] + defp extract_node_attr({attr, {:expr, expr, _}, _}), do: [attr, ?=, ?{, expr, ?}, " "] + + def encode_eex_block(value) when is_tuple(value) do + value + |> Tuple.to_list() + |> Enum.reduce([], fn node, acc -> [encode_block_node(node) | acc] end) + |> Enum.reverse() + end + + def encode_eex_block(value, opts) when is_tuple(value) do + value + |> encode_eex_block() + |> Jason.Encode.list(opts) + end + + def encode_block_node(nodes) when is_list(nodes) do + nodes + |> Enum.reduce([], fn node, acc -> [encode_block_node(node) | acc] end) + |> Enum.reverse() + end + + def encode_block_node(node) when is_tuple(node) do + [type | content] = Tuple.to_list(node) + %{type: type, content: content |> List.flatten() |> encode_block_node()} + end + + def encode_block_node(node), do: node end diff --git a/lib/beacon_web/controllers/api/page_json.ex b/lib/beacon_web/controllers/api/page_json.ex index 655ea353..43f954aa 100644 --- a/lib/beacon_web/controllers/api/page_json.ex +++ b/lib/beacon_web/controllers/api/page_json.ex @@ -34,8 +34,11 @@ defmodule BeaconWeb.API.PageJSON do defp page_ast(page) do path = for segment <- String.split(page.path, "/"), segment != "", do: segment beacon_live_data = Beacon.DataSource.live_data(page.site, path, []) - {:ok, ast} = Beacon.Template.HEEx.JSONEncoder.encode(page.site, page.template, %{beacon_live_data: beacon_live_data}) - ast + + case Beacon.Template.HEEx.JSONEncoder.encode(page.site, page.template, %{beacon_live_data: beacon_live_data}) do + {:ok, ast} -> ast + _ -> [] + end end defp maybe_include_layout(%{template: page_template} = data, %Page{layout: %Layout{} = layout}) do diff --git a/test/beacon/template/heex/heex_decoder_test.exs b/test/beacon/template/heex/heex_decoder_test.exs index 26866dd2..8c3d2835 100644 --- a/test/beacon/template/heex/heex_decoder_test.exs +++ b/test/beacon/template/heex/heex_decoder_test.exs @@ -51,6 +51,17 @@ defmodule Beacon.Template.HEEx.HEExDecoderTest do """) end + test "comprehensions" do + assert_equal( + ~S""" + <%= for val <- @beacon_live_data[:vals] do %> + <%= val %> + <% end %> + """, + %{beacon_live_data: %{vals: [1]}} + ) + end + test "function components" do assert_equal(~S||) assert_equal(~S|<.link path="/contact" replace={true}>Book meeting|) @@ -64,7 +75,7 @@ defmodule Beacon.Template.HEEx.HEExDecoderTest do assert_equal(~S|<%= my_component("sample_component", %{val: 1}) %>|) end - test "beacon_live_data assigns" do + test "live data assigns" do assert_equal(~S|<%= @beacon_live_data[:name] %>|, %{beacon_live_data: %{name: "Beacon"}}) end end diff --git a/test/beacon/template/heex/json_encoder_test.exs b/test/beacon/template/heex/json_encoder_test.exs index 430743b5..ebd49e35 100644 --- a/test/beacon/template/heex/json_encoder_test.exs +++ b/test/beacon/template/heex/json_encoder_test.exs @@ -54,11 +54,13 @@ defmodule Beacon.Template.HEEx.JSONEncoderTest do ~S|value: <%= 1 %>|, ["value: ", %{"attrs" => %{}, "content" => ["1"], "metadata" => %{"opt" => ~c"="}, "rendered_html" => "1", "tag" => "eex"}] ) + end + test "block expressions" do assert_output( ~S""" <%= if @completed do %> -
<%= @completed_message %>
+ <%= @completed_message %> <% else %> Keep working <% end %> @@ -74,19 +76,13 @@ defmodule Beacon.Template.HEEx.JSONEncoderTest do "content" => [ %{ "attrs" => %{}, - "content" => [ - %{ - "attrs" => %{}, - "content" => ["@completed_message"], - "metadata" => %{"opt" => ~c"="}, - "rendered_html" => "Congrats", - "tag" => "eex" - } - ], - "tag" => "span" + "content" => ["@completed_message"], + "metadata" => %{"opt" => ~c"="}, + "rendered_html" => "Congrats", + "tag" => "eex" } ], - "tag" => "div" + "tag" => "span" } ], "key" => "else" @@ -149,19 +145,58 @@ defmodule Beacon.Template.HEEx.JSONEncoderTest do ) end - @tag :skip - test "comprehensions" do + test "live data" do assert_output( - ~S| - <%= for val <- @beacon_live_data[:vals] do %> - <%= my_component("sample_component", val: val) %> - <% end %> - |, - [], - %{beacon_live_data: %{vals: [1, 2]}} + "<%= inspect(@beacon_live_data[:vals]) %>", + [ + %{ + "attrs" => %{}, + "content" => ["inspect(@beacon_live_data[:vals])"], + "metadata" => %{"opt" => ~c"="}, + "rendered_html" => "[1, 2, 3]", + "tag" => "eex" + } + ], + %{beacon_live_data: %{vals: [1, 2, 3]}} ) end + test "comprehensions" do + template = ~S| + <%= for employee <- @beacon_live_data[:employees] do %> + --> + <%= employee.position %> +
+ <%= for person <- @beacon_live_data[:persons] do %> + <%= if person.id == employee.id do %> + <%= person.name %> + + <% end %> + <% end %> +
+ <% end %> + | + + assert {:ok, + [ + %{ + "arg" => "for employee <- @beacon_live_data[:employees] do", + "tag" => "eex_block", + "rendered_html" => + "\n -->\nCEO\n
\n\n\n José\n \n\n\n\n\n
\n\n -->\nManager\n
\n\n\n\n\n Chris\n \n\n\n
\n", + "ast" => ast + } + ]} = + JSONEncoder.encode(:my_site, template, %{ + beacon_live_data: %{ + employees: [%{id: 1, position: "CEO"}, %{id: 2, position: "Manager"}], + persons: [%{id: 1, name: "José", picture: "profile.jpg"}, %{id: 2, name: "Chris", picture: nil}] + } + }) + + assert is_binary(ast) + end + test "function components" do assert_output( ~S||, @@ -237,8 +272,91 @@ defmodule Beacon.Template.HEEx.JSONEncoderTest do end test "invalid template" do - assert_raise Beacon.ParserError, fn -> - JSONEncoder.encode(:my_site, ~S|<%= :error|) - end + assert {:error, _} = JSONEncoder.encode(:my_site, ~S|<%= :error|) + end + + test "encode eex_block" do + ast = + {[ + {:html_comment, [{:text, " -->", %{}}]}, + {:eex, "employee.position", %{line: 4, opt: ~c"=", column: 11}}, + {:text, "\n ", %{newlines: 1}}, + {:tag_block, "div", [], + [ + {:text, "\n ", %{newlines: 1}}, + {:eex_block, "for person <- @beacon_live_data[:persons] do", + [ + {[ + {:text, "\n ", %{newlines: 1}}, + {:eex_block, "if person.id == employee.id do", + [ + {[ + {:text, "\n ", %{newlines: 1}}, + {:tag_block, "span", [], [{:eex, "person.name", %{line: 8, opt: ~c"=", column: 23}}], %{mode: :inline}}, + {:text, "\n ", %{newlines: 1}}, + {:tag_self_close, "img", + [ + {"src", {:expr, "if person.picture , do: person.picture, else: \"default.jpg\"", %{line: 9, column: 27}}, + %{line: 9, column: 22}}, + {"width", {:string, "200", %{delimiter: 34}}, %{line: 9, column: 88}} + ]}, + {:text, "\n ", %{newlines: 1}} + ], "end"} + ]}, + {:text, "\n ", %{newlines: 1}} + ], "end"} + ]}, + {:text, "\n ", %{newlines: 1}} + ], %{mode: :block}}, + {:text, "\n ", %{newlines: 1}} + ], "end"} + + assert JSONEncoder.encode_eex_block(ast) == + [ + [ + %{type: :html_comment, content: [%{type: :text, content: [" -->", %{}]}]}, + %{type: :eex, content: ["employee.position", %{line: 4, opt: ~c"=", column: 11}]}, + %{type: :text, content: ["\n ", %{newlines: 1}]}, + %{ + type: :tag_block, + content: [ + "div", + %{type: :text, content: ["\n ", %{newlines: 1}]}, + %{ + type: :eex_block, + content: [ + "for person <- @beacon_live_data[:persons] do", + %{ + type: [ + {:text, "\n ", %{newlines: 1}}, + {:eex_block, "if person.id == employee.id do", + [ + {[ + {:text, "\n ", %{newlines: 1}}, + {:tag_block, "span", [], [{:eex, "person.name", %{line: 8, opt: ~c"=", column: 23}}], %{mode: :inline}}, + {:text, "\n ", %{newlines: 1}}, + {:tag_self_close, "img", + [ + {"src", {:expr, "if person.picture , do: person.picture, else: \"default.jpg\"", %{line: 9, column: 27}}, + %{line: 9, column: 22}}, + {"width", {:string, "200", %{delimiter: 34}}, %{line: 9, column: 88}} + ]}, + {:text, "\n ", %{newlines: 1}} + ], "end"} + ]}, + {:text, "\n ", %{newlines: 1}} + ], + content: ["end"] + } + ] + }, + %{type: :text, content: ["\n ", %{newlines: 1}]}, + %{mode: :block} + ] + }, + %{type: :text, content: ["\n ", %{newlines: 1}]} + ], + "end" + ] end end diff --git a/test/beacon/template/heex/tokenizer_test.exs b/test/beacon/template/heex/tokenizer_test.exs index f10af2bd..a9e4533d 100644 --- a/test/beacon/template/heex/tokenizer_test.exs +++ b/test/beacon/template/heex/tokenizer_test.exs @@ -3,64 +3,100 @@ defmodule Beacon.Template.HEEx.TokenizerTest do alias Beacon.Template.HEEx.Tokenizer - test "tokenizes a complex template" do - template = ~S| -
-

<%= user.name %>

+ test "inline expression" do + assert Tokenizer.tokenize(~S| + <%= user.name %> + |) == {:ok, [{:eex, "user.name", %{line: 2, opt: ~c"=", column: 7}}]} + end + + test "block expression" do + assert Tokenizer.tokenize(~S| <%= if true do %>

this

<% else %>

that

<% end %> -
- - | + |) == + {:ok, + [ + {:eex_block, "if true do", + [ + {[ + {:text, "\n ", %{newlines: 1}}, + {:tag_block, "p", [], [{:text, "this", %{newlines: 0}}], %{mode: :block}}, + {:text, "\n ", %{newlines: 1}} + ], "else"}, + {[ + {:text, "\n ", %{newlines: 1}}, + {:tag_block, "p", [], [{:text, "that", %{newlines: 0}}], %{mode: :block}}, + {:text, "\n ", %{newlines: 1}} + ], "end"} + ]} + ]} + end - assert Tokenizer.tokenize(template) == { + test "comprehension" do + assert Tokenizer.tokenize(~S| + <%= for employee <- @beacon_live_data[:employees] do %> + --> + <%= employee.position %> +
+ <%= for person <- @beacon_live_data[:persons] do %> + <%= if person.id == employee.id do %> + <%= person.name %> + + <% end %> + <% end %> +
+ <% end %> + |) == { :ok, [ { - :tag_block, - "section", - [], + :eex_block, + "for employee <- @beacon_live_data[:employees] do", [ - {:text, "\n ", %{newlines: 1}}, - {:tag_block, "p", [], [{:eex, "user.name", %{column: 10, line: 3, opt: ~c"="}}], %{mode: :block}}, - {:text, "\n ", %{newlines: 1}}, { - :eex_block, - "if true do", [ + {:html_comment, [{:text, " -->", %{}}]}, + {:eex, "employee.position", %{column: 9, line: 4, opt: ~c"="}}, + {:text, "\n ", %{newlines: 1}}, { + :tag_block, + "div", + [], [ - {:text, "\n ", %{newlines: 1}}, - {:tag_block, "p", [], [{:text, "this", %{newlines: 0}}], %{mode: :block}}, - {:text, "\n ", %{newlines: 1}} + {:text, "\n ", %{newlines: 1}}, + {:eex_block, "for person <- @beacon_live_data[:persons] do", + [ + {[ + {:text, "\n ", %{newlines: 1}}, + {:eex_block, "if person.id == employee.id do", + [ + {[ + {:text, "\n ", %{newlines: 1}}, + {:tag_block, "span", [], [{:eex, "person.name", %{column: 21, line: 8, opt: ~c"="}}], %{mode: :inline}}, + {:text, "\n ", %{newlines: 1}}, + {:tag_self_close, "img", + [ + {"src", {:expr, "if person.picture , do: person.picture, else: \"default.jpg\"", %{column: 25, line: 9}}, + %{column: 20, line: 9}}, + {"width", {:string, "200", %{delimiter: 34}}, %{column: 86, line: 9}} + ]}, + {:text, "\n ", %{newlines: 1}} + ], "end"} + ]}, + {:text, "\n ", %{newlines: 1}} + ], "end"} + ]}, + {:text, "\n ", %{newlines: 1}} ], - "else" + %{mode: :block} }, - { - [ - {:text, "\n ", %{newlines: 1}}, - {:tag_block, "p", [], [{:text, "that", %{newlines: 0}}], %{mode: :block}}, - {:text, "\n ", %{newlines: 1}} - ], - "end" - } - ] - }, - {:text, "\n ", %{newlines: 1}} - ], - %{mode: :block} - }, - {:text, "\n ", %{newlines: 1}}, - { - :tag_self_close, - "BeaconWeb.Components.image_set", - [ - {"asset", {:expr, "@beacon_live_data[:img1]", %{column: 44, line: 10}}, %{column: 37, line: 10}}, - {"sources", {:expr, "[\"480w\"]", %{column: 79, line: 10}}, %{column: 70, line: 10}}, - {"width", {:string, "200px", %{delimiter: 34}}, %{column: 89, line: 10}} + {:text, "\n ", %{newlines: 1}} + ], + "end" + } ] } ] diff --git a/test/beacon/template/heex_test.exs b/test/beacon/template/heex_test.exs index 99349691..8724e52f 100644 --- a/test/beacon/template/heex_test.exs +++ b/test/beacon/template/heex_test.exs @@ -17,6 +17,18 @@ defmodule Beacon.Template.HEExTest do assert HEEx.render(:my_site, ~S|<%= 1 + @value %>|, %{value: 1}) == "2" end + test "comprehensions" do + assert HEEx.render( + :my_site, + ~S| + <%= for val <- @beacon_live_data[:vals] do %> + <%= val %> + <% end %> + |, + %{beacon_live_data: %{vals: [1, 2]}} + ) == "\n1\n\n2\n" + end + test "user defined components" do start_supervised!({Beacon.Loader, Beacon.Config.fetch!(:my_site)}) component_fixture(site: "my_site", name: "sample")