diff --git a/lib/hologram/template/dom_tree.ex b/lib/hologram/template/dom_tree.ex new file mode 100644 index 0000000000..ce57c71ee7 --- /dev/null +++ b/lib/hologram/template/dom_tree.ex @@ -0,0 +1,216 @@ +defmodule Hologram.Template.DOMTree do + @moduledoc """ + Converts the output of the template parser into a DOMTree structure. + """ + + alias Hologram.Template.Helpers + alias Hologram.Template.Parser + + defmodule Node do + @type t :: %__MODULE__{ + type: :component | :element | :text | :expression | :block | :doctype | :comment, + name: String.t() | nil, + attributes: list({String.t(), any()}) | nil, + children: Hologram.Template.DOMTree.nodes() | nil, + content: String.t() | any() | nil, + module: module() | nil + } + + defstruct [:type, :name, :content, :module, attributes: [], children: []] + end + + @type nodes :: list(Node.t()) + + @doc """ + Converts a list of parsed tags into a list of DOMTree nodes. + """ + @spec from_parse(list(Parser.parsed_tag())) :: {:ok, nodes()} | {:error, any()} + def from_parse(tags) do + process(tags, [], []) + end + + # End of input + defp process([], [], roots) do + {:ok, Enum.reverse(roots)} + end + + defp process([], [{node, _children} | tail], _roots) do + {:error, {:unclosed_tag, node, parents(tail)}} + end + + # Text + defp process([{:text, content} | rest], stack, roots) do + node = %Node{type: :text, content: content} + add_node(node, rest, stack, roots) + end + + # Start Tag + defp process([{:start_tag, {tag_name, attrs}} | rest], stack, roots) do + node = %Node{ + type: Helpers.tag_type(tag_name), + name: tag_name, + attributes: attrs + } + + process(rest, [{node, []} | stack], roots) + end + + # Self Closing Tag + defp process([{:self_closing_tag, {tag_name, attrs}} | rest], stack, roots) do + node = %Node{ + type: Helpers.tag_type(tag_name), + name: tag_name, + attributes: attrs + } + + add_node(node, rest, stack, roots) + end + + # End Tag + defp process([{:end_tag, tag_name} | rest], stack, roots) do + handle_end_tag(tag_name, :html, rest, stack, roots) + end + + # Block Start: Else + defp process([{:block_start, "else"} | _rest], [], _roots) do + {:error, {:unexpected_tag, "else", []}} + end + + defp process([{:block_start, "else"} | rest], stack, roots) do + case stack do + [{%Node{type: :block, name: "if"}, _children} | _tail] -> + node = %Node{type: :block, name: "else"} + process(rest, [{node, []} | stack], roots) + + _stack -> + {:error, {:unexpected_tag, "else", parents(stack)}} + end + end + + # Block Start: Raw + defp process([{:block_start, "raw"} | rest], stack, roots) do + node = %Node{type: :block, name: "raw"} + process(rest, [{node, []} | stack], roots) + end + + # Block Start: Generic + defp process([{:block_start, {name, expr}} | rest], stack, roots) do + node = %Node{ + type: :block, + name: name, + content: expr + } + + process(rest, [{node, []} | stack], roots) + end + + # Block End + defp process([{:block_end, name} | rest], stack, roots) do + handle_end_tag(name, :block, rest, stack, roots) + end + + # Expression + defp process([{:expression, content} | rest], stack, roots) do + node = %Node{ + type: :expression, + content: content + } + + add_node(node, rest, stack, roots) + end + + # Doctype + defp process([{:doctype, content} | rest], stack, roots) do + node = %Node{type: :doctype, content: content} + add_node(node, rest, stack, roots) + end + + # Comment Start + defp process([:public_comment_start | rest], stack, roots) do + node = %Node{type: :comment} + process(rest, [{node, []} | stack], roots) + end + + # Comment End + defp process([:public_comment_end | rest], stack, roots) do + case stack do + [{%{type: :comment} = node, children} | stack_tail] -> + finished_node = %{node | children: Enum.reverse(children)} + add_node(finished_node, rest, stack_tail, roots) + + _stack -> + {:error, {:unexpected_closing_tag, "-->", :html, parents(stack)}} + end + end + + # Helpers + + defp add_node(node, rest, [], roots) do + process(rest, [], [node | roots]) + end + + defp add_node(node, rest, [{parent, children} | tail], roots) do + new_stack = [{parent, [node | children]} | tail] + process(rest, new_stack, roots) + end + + defp handle_end_tag(tag_name, kind, rest, stack, roots) do + case stack do + [{%{name: ^tag_name} = node, children} | stack_tail] -> + finished_node = %{node | children: Enum.reverse(children)} + add_node(finished_node, rest, stack_tail, roots) + + [{%{name: "else"}, _else_children} | [{%{name: "if"}, _if_children} | _if_tail]] + when tag_name == "if" -> + handle_implicit_else_closing(rest, stack, roots) + + _stack -> + {:error, {:unexpected_closing_tag, tag_name, kind, parents(stack)}} + end + end + + defp handle_implicit_else_closing(rest, stack, roots) do + [ + {%{name: "else"} = else_node, else_children} + | [{%{name: "if"} = if_node, if_children} | if_tail] + ] = stack + + finished_else = %{else_node | children: Enum.reverse(else_children)} + new_if_children = [finished_else | if_children] + + finished_if = %{if_node | children: Enum.reverse(new_if_children)} + add_node(finished_if, rest, if_tail, roots) + end + + defp parents(stack) do + Enum.map(stack, fn {node, _children} -> node end) + end + + @doc """ + Traverses the DOM tree(s) in a map/reduce style (depth-first, pre-order). + + The callback function should take a node and an accumulator, and return + a tuple `{new_node, new_accumulator}`. This is useful for transforming the + tree while collecting data, such as a list of used components. + """ + @spec traverse(Node.t() | nodes(), any(), (Node.t(), any() -> {Node.t(), any()})) :: + {Node.t() | nodes(), any()} + def traverse(nodes, acc, callback) when is_list(nodes) and is_function(callback, 2) do + Enum.map_reduce(nodes, acc, fn node, current_acc -> + traverse(node, current_acc, callback) + end) + end + + def traverse(node, acc, callback) when is_function(callback, 2) do + {new_node, new_acc} = callback.(node, acc) + + case new_node.children do + children when is_list(children) -> + {new_children, final_acc} = traverse(children, new_acc, callback) + {%{new_node | children: new_children}, final_acc} + + _children -> + {new_node, new_acc} + end + end +end diff --git a/test/elixir/hologram/template/dom_tree_test.exs b/test/elixir/hologram/template/dom_tree_test.exs new file mode 100644 index 0000000000..45b14fbdaf --- /dev/null +++ b/test/elixir/hologram/template/dom_tree_test.exs @@ -0,0 +1,435 @@ +defmodule Hologram.Template.DOMTreeTest do + use Hologram.Test.BasicCase, async: true + + import Hologram.Template.DOMTree, only: [from_parse: 1] + + alias Hologram.Template.DOMTree + alias Hologram.Template.DOMTree.Node + + test "parses simple text" do + result = + "Hello world" + |> parsed_tags() + |> from_parse() + + assert result == {:ok, [%Node{type: :text, content: "Hello world"}]} + end + + test "parses simple element" do + result = + "
" + |> parsed_tags() + |> from_parse() + + assert result == + {:ok, + [ + %Node{ + type: :element, + name: "div", + attributes: [{"id", [text: "test"]}], + children: [] + } + ]} + end + + test "parses self-closing element" do + result = + "
" + |> parsed_tags() + |> from_parse() + + assert result == {:ok, [%Node{type: :element, name: "br", children: []}]} + end + + test "parses component" do + result = + "" + |> parsed_tags() + |> from_parse() + + assert result == + {:ok, + [ + %Node{ + type: :component, + name: "MyComponent", + attributes: [{"prop", [text: "val"]}], + children: [] + } + ]} + end + + test "parses nested elements" do + result = + "
Text
" + |> parsed_tags() + |> from_parse() + + assert result == + {:ok, + [ + %Node{ + type: :element, + name: "div", + children: [ + %Node{ + type: :element, + name: "span", + children: [ + %Node{type: :text, content: "Text"} + ] + } + ] + } + ]} + end + + test "respects explicit CIDs in markup as attributes" do + {:ok, [div]} = + ~s(
) + |> parsed_tags() + |> from_parse() + + # ensure CIDs are preserved as standard attributes + assert [{"cid", [text: "custom_id"]}] = div.attributes + + [span] = div.children + assert [{"cid", [text: "nested_id"]}] = span.attributes + end + + test "parses expression" do + result = + "{1 + 1}" + |> parsed_tags() + |> from_parse() + + assert result == {:ok, [%Node{type: :expression, content: "{1 + 1}"}]} + end + + test "parses doctype" do + result = + "" + |> parsed_tags() + |> from_parse() + + assert result == {:ok, [%Node{type: :doctype, content: "html"}]} + end + + test "parses comments" do + result = + "" + |> parsed_tags() + |> from_parse() + + assert result == + {:ok, + [ + %Node{ + type: :comment, + children: [ + %Node{type: :text, content: " comment "} + ] + } + ]} + end + + test "parses if block" do + result = + "{%if true}content{/if}" + |> parsed_tags() + |> from_parse() + + assert result == + {:ok, + [ + %Node{ + type: :block, + name: "if", + content: "{ true}", + children: [ + %Node{type: :text, content: "content"} + ] + } + ]} + end + + test "parses if/else block" do + result = + "{%if true}yes{%else}no{/if}" + |> parsed_tags() + |> from_parse() + + assert result == + {:ok, + [ + %Node{ + type: :block, + name: "if", + content: "{ true}", + children: [ + %Node{type: :text, content: "yes"}, + %Node{ + type: :block, + name: "else", + children: [ + %Node{type: :text, content: "no"} + ] + } + ] + } + ]} + end + + test "parses for block" do + result = + "{%for item <- list}item{/for}" + |> parsed_tags() + |> from_parse() + + assert result == + {:ok, + [ + %Node{ + type: :block, + name: "for", + content: "{ item <- list}", + children: [ + %Node{type: :text, content: "item"} + ] + } + ]} + end + + test "handles unclosed tag error" do + result = + "
" + |> parsed_tags() + |> from_parse() + + assert result == {:error, {:unclosed_tag, %Node{type: :element, name: "div"}, []}} + end + + test "handles mismatched closing tag error" do + result = + "
" + |> parsed_tags() + |> from_parse() + + assert result == + {:error, + {:unexpected_closing_tag, "span", :html, [%Node{type: :element, name: "div"}]}} + end + + test "handles unexpected else tag error" do + result = + "
{%else}
" + |> parsed_tags() + |> from_parse() + + assert result == {:error, {:unexpected_tag, "else", [%Node{type: :element, name: "div"}]}} + end + + test "parses complex template with nesting and blocks" do + markup = ~s( + +
+ + {%if @authenticated} + +
+ +
    + + {%for item <- @items} + +
  • + + {item.label} + + {%if item.urgent} + + Urgent + + {%else} + + Regular + + {/if} + +
  • + + {/for} + +
+ + {%else} + +

Please login.

+ + {/if} + +
Page {1 + 2}
+ +
+ + ) + + {:ok, nodes} = + markup + |> parsed_tags() + |> from_parse() + + {_nodes, result_map} = + DOMTree.traverse(nodes, %{components: [], blocks: [], elements: []}, fn node, acc -> + updated_acc = + case node do + %Node{type: :component, name: name} -> + Map.update!(acc, :components, &[name | &1]) + + %Node{type: :block, name: name} -> + Map.update!(acc, :blocks, &[name | &1]) + + %Node{type: :element, name: name} -> + Map.update!(acc, :elements, &[name | &1]) + + _node -> + acc + end + + {node, updated_acc} + end) + + assert result_map.components == ["Header"] + # Blocks are collected in pre-order traversal + # Div -> If -> (Header, Ul -> For -> (Li -> (If -> (Else), Else))) -> Else -> Footer + # But traversal order depends on implementation. + # traverse is depth-first pre-order. + # 1. Div (element) + # 2. If (@authenticated) (block) + # 3. Header (component) + # 4. Ul (element) + # 5. For (@items) (block) + # 6. Li (element) + # 7. If (urgent) (block) + # 8. Span (badge) (element) + # 9. Else (block) + # 10. Span (regular) (element) + # 11. Else (block) + # 12. P (element) + # 13. Footer (element) + # + # Result list is prepended, so reverse order of traversal. + + assert "if" in result_map.blocks + assert "for" in result_map.blocks + assert "else" in result_map.blocks + + assert "div" in result_map.elements + assert "footer" in result_map.elements + assert "li" in result_map.elements + assert "ul" in result_map.elements + assert "span" in result_map.elements + assert "p" in result_map.elements + end + + describe "edge cases" do + test "parses raw block" do + result = + "{ %raw}
{/raw}" + |> parsed_tags() + |> from_parse() + + assert {:ok, _nodes} = result + end + + test "parses boolean attributes" do + {:ok, [node]} = + "" + |> parsed_tags() + |> from_parse() + + assert [{"disabled", []}] = node.attributes + end + + test "parses script tag content" do + result = + "" + |> parsed_tags() + |> from_parse() + + assert {:ok, [node]} = result + assert node.type == :element + assert node.name == "script" + assert [%{type: :text, content: "console.log('
');"}] = node.children + end + + test "parses mixed attributes" do + {:ok, [node]} = + "
" + |> parsed_tags() + |> from_parse() + + assert [{"class", [text: "a ", expression: "{b}", text: ""]}] = node.attributes + end + + test "parses multiple fragments" do + result = + "
" + |> parsed_tags() + |> from_parse() + + assert result == + {:ok, [%Node{type: :element, name: "div"}, %Node{type: :element, name: "span"}]} + end + end + + describe "traverse/3" do + test "traverses and transforms nodes while accumulating state" do + nodes = + [ + struct(Node, + type: :element, + name: "div", + children: [ + struct(Node, type: :text, content: "a"), + struct(Node, type: :text, content: "b") + ] + ), + struct(Node, type: :text, content: "c") + ] + + callback = fn + %{type: :text} = node, acc -> + {%{node | content: String.upcase(node.content)}, [node.content | acc]} + + node, acc -> + {node, acc} + end + + {new_nodes, acc} = DOMTree.traverse(nodes, [], callback) + + assert acc == ["c", "b", "a"] + + assert( + [ + %Node{ + name: "div", + children: [ + %Node{content: "A"}, + %Node{content: "B"} + ] + }, + %Node{content: "C"} + ] = new_nodes + ) + end + + test "handles single node" do + node = struct(Node, type: :text, content: "hello") + callback = fn node, acc -> {%{node | content: "world"}, acc + 1} end + + assert {%Node{content: "world"}, 1} = DOMTree.traverse(node, 0, callback) + end + end +end