Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve HEEx encoder/decoder #396

Merged
merged 11 commits into from
Jan 26, 2024
3 changes: 3 additions & 0 deletions lib/beacon/jason.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
defimpl Jason.Encoder, for: Tuple do
defdelegate encode(value, opts), to: Beacon.Template.HEEx.JSONEncoder, as: :encode_eex_block
end
28 changes: 27 additions & 1 deletion lib/beacon/template/heex/heex_decoder.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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), "/>"]
Expand Down Expand Up @@ -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
130 changes: 112 additions & 18 deletions lib/beacon/template/heex/json_encoder.ex
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@ defmodule Beacon.Template.HEEx.JSONEncoder do
Note that:

* `rendered_html` key is optional
* Comprehensions are not supported

## Example

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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" ->
Expand All @@ -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
Expand Down Expand Up @@ -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), "</", tag, ">"]
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
7 changes: 5 additions & 2 deletions lib/beacon_web/controllers/api/page_json.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 12 additions & 1 deletion test/beacon/template/heex/heex_decoder_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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|<BeaconWeb.Components.image name="logo.jpg" width="200px" />|)
assert_equal(~S|<.link path="/contact" replace={true}>Book meeting</.link>|)
Expand All @@ -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
Loading
Loading