From 1d473dbc971d5ddd3e663f193d6b8d1619b90b56 Mon Sep 17 00:00:00 2001 From: Steffen Deusch Date: Thu, 22 Jan 2026 18:59:25 +0100 Subject: [PATCH 1/5] Add Phoenix.LiveView.TagCompiler namespace This commit moves the Tokenizer into the Phoenix.LiveView.TagCompiler.Tokenizer module and also adds a Parser that builds a tree similar to what the HTMLFormatter previously did. We're going to use that node tree for compiling templates in a future commit. The HTMLFormatter and HTMLAlgebra were now use this tree format. --- lib/phoenix_live_view/html_algebra.ex | 30 +- lib/phoenix_live_view/html_formatter.ex | 408 ++++++---------- lib/phoenix_live_view/tag_compiler.ex | 29 ++ lib/phoenix_live_view/tag_compiler/parser.ex | 437 ++++++++++++++++++ .../{ => tag_compiler}/tokenizer.ex | 2 +- lib/phoenix_live_view/tag_engine.ex | 4 +- .../macro_component_integration_test.exs | 15 +- .../phoenix_live_view/colocated_hook_test.exs | 2 +- test/phoenix_live_view/colocated_js_test.exs | 2 +- .../{ => heex}/tokenizer_test.exs | 6 +- test/phoenix_live_view/html_engine_test.exs | 2 +- .../phoenix_live_view/html_formatter_test.exs | 11 +- 12 files changed, 640 insertions(+), 308 deletions(-) create mode 100644 lib/phoenix_live_view/tag_compiler.ex create mode 100644 lib/phoenix_live_view/tag_compiler/parser.ex rename lib/phoenix_live_view/{ => tag_compiler}/tokenizer.ex (99%) rename test/phoenix_live_view/{ => heex}/tokenizer_test.exs (99%) diff --git a/lib/phoenix_live_view/html_algebra.ex b/lib/phoenix_live_view/html_algebra.ex index 9a2aca12f2..e41e3c7d0e 100644 --- a/lib/phoenix_live_view/html_algebra.ex +++ b/lib/phoenix_live_view/html_algebra.ex @@ -173,11 +173,11 @@ defmodule Phoenix.LiveView.HTMLAlgebra do end end - defp tag_block?({:tag_block, _, _, _, _}), do: true + defp tag_block?({:block, _, _, _, _, _}), do: true defp tag_block?(_node), do: false - defp tag?({:tag_block, _, _, _, _}), do: true - defp tag?({:tag_self_close, _, _}), do: true + defp tag?({:block, _, _, _, _, _}), do: true + defp tag?({:self_close, _, _, _, _}), do: true defp tag?(_node), do: false defp text?({:text, _, _}), do: true @@ -195,7 +195,7 @@ defmodule Phoenix.LiveView.HTMLAlgebra do defp text_ends_with_space?(_node), do: false - defp block_preserve?({:tag_block, _, _, _, %{mode: :preserve}}), do: true + defp block_preserve?({:block, _, _, _, _, %{mode: :preserve}}), do: true defp block_preserve?({:body_expr, _, _}), do: true defp block_preserve?({:eex, _, _}), do: true defp block_preserve?(_node), do: false @@ -208,7 +208,8 @@ defmodule Phoenix.LiveView.HTMLAlgebra do {:block, group(nest(children, :reset))} end - defp to_algebra({:tag_block, name, attrs, block, meta}, context) when name in @languages do + defp to_algebra({:block, _type, _name, attrs, block, %{tag_name: name} = meta}, context) + when name in @languages do children = block_to_algebra(block, %{context | mode: :preserve}) # Convert the whole block to text as there are no @@ -253,7 +254,10 @@ defmodule Phoenix.LiveView.HTMLAlgebra do {:block, group} end - defp to_algebra({:tag_block, name, attrs, block, meta}, %{mode: :preserve} = context) do + defp to_algebra( + {:block, _type, _name, attrs, block, %{tag_name: name} = meta}, + %{mode: :preserve} = context + ) do children = block_to_algebra(block, context) children = @@ -270,11 +274,11 @@ defmodule Phoenix.LiveView.HTMLAlgebra do {:inline, tag} end - defp to_algebra({:tag_block, _name, _attrs, _block, %{mode: :preserve}} = doc, context) do + defp to_algebra({:block, _type, _name, _attrs, _block, %{mode: :preserve}} = doc, context) do to_algebra(doc, %{context | mode: :preserve}) end - defp to_algebra({:tag_block, name, attrs, block, _meta}, context) do + defp to_algebra({:block, _type, _name, attrs, block, %{tag_name: name}}, context) do inline? = inline?(name, context) {block, force_newline?} = trim_block_newlines(block, inline?) inline? = inline? and not force_newline? @@ -304,7 +308,7 @@ defmodule Phoenix.LiveView.HTMLAlgebra do end end - defp to_algebra({:tag_self_close, name, attrs}, context) do + defp to_algebra({:self_close, _type, _name, attrs, %{tag_name: name}}, context) do doc = concat([ "<#{name}", @@ -318,7 +322,7 @@ defmodule Phoenix.LiveView.HTMLAlgebra do # Handle EEX blocks within preserve tags defp to_algebra({:eex_block, expr, block, meta}, %{mode: :preserve} = context) do doc = - Enum.reduce(block, empty(), fn {block, expr}, doc -> + Enum.reduce(block, empty(), fn {block, expr, _clause_meta}, doc -> children = block_to_algebra(block, context) expr = "<% #{expr} %>" concat([doc, children, expr]) @@ -330,7 +334,7 @@ defmodule Phoenix.LiveView.HTMLAlgebra do # Handle EEX blocks defp to_algebra({:eex_block, expr, block, meta}, context) do {doc, _stab} = - Enum.reduce(block, {empty(), false}, fn {block, expr}, {doc, stab?} -> + Enum.reduce(block, {empty(), false}, fn {block, expr, _clause_meta}, {doc, stab?} -> {block, _force_newline?} = trim_block_newlines(block, false) {next_doc, stab?} = eex_block_to_algebra(expr, block, stab?, context) {concat(doc, force_unfit(next_doc)), stab?} @@ -529,8 +533,8 @@ defmodule Phoenix.LiveView.HTMLAlgebra do # Handle EEx clauses # - # {[], "something ->"} - # {[{:tag_block, "p", [], [text: "do something"]}], "else"} + # {[], "something ->", %{...}} + # {[{:block, :tag, "p", [], [...], %{...}}], "else", %{...}} defp eex_block_to_algebra(expr, block, stab?, context) when is_list(block) do indent = if stab?, do: 4, else: 2 diff --git a/lib/phoenix_live_view/html_formatter.ex b/lib/phoenix_live_view/html_formatter.ex index 1ef10761a8..639a18609d 100644 --- a/lib/phoenix_live_view/html_formatter.ex +++ b/lib/phoenix_live_view/html_formatter.ex @@ -269,11 +269,8 @@ defmodule Phoenix.LiveView.HTMLFormatter do require Logger alias Phoenix.LiveView.HTMLAlgebra - alias Phoenix.LiveView.Tokenizer - alias Phoenix.LiveView.Tokenizer.ParseError - - defguard is_tag_open(tag_type) - when tag_type in [:slot, :remote_component, :local_component, :tag] + alias Phoenix.LiveView.TagCompiler.Parser + alias Phoenix.LiveView.TagCompiler.Tokenizer.ParseError # Default line length to be used in case nothing is specified in the `.formatter.exs` options. @default_line_length 98 @@ -307,11 +304,17 @@ defmodule Phoenix.LiveView.HTMLFormatter do formatted = source - |> tokenize() - |> to_tree([], [], %{source: {source, newlines}}) + |> Parser.parse( + tag_handler: Phoenix.LiveView.HTMLEngine, + file: "nofile", + skip_macro_components: true, + prune_text_after_slots: false, + process_buffer: &process_buffer/1 + ) |> case do {:ok, nodes} -> nodes + |> transform_tree(source, newlines) |> HTMLAlgebra.build(opts) |> Inspect.Algebra.format(line_length) @@ -331,331 +334,179 @@ defmodule Phoenix.LiveView.HTMLFormatter do end end - # Tokenize contents using EEx.tokenize and Phoenix.Live.Tokenizer respectively. - # - # The following content: - # - # "
\n

<%= user.name >

\n <%= if true do %>

this

<% else %>

that

<% end %>\n
\n" - # - # Will be tokenized as: - # - # [ - # {:tag, "section", [], %{column: 1, line: 1}}, - # {:text, "\n ", %{column_end: 3, line_end: 2}}, - # {:tag, "p", [], %{column: 3, line: 2}}, - # {:eex_tag_render, "<%= user.name >

\n <%= if true do %>", %{block?: true, column: 6, line: 1}}, - # {:text, " ", %{column_end: 2, line_end: 1}}, - # {:tag, "p", [], %{column: 2, line: 1}}, - # {:text, "this", %{column_end: 12, line_end: 1}}, - # {::close, :tag, "p", %{column: 12, line: 1}}, - # {:eex_tag, "<% else %>", %{block?: false, column: 35, line: 2}}, - # {:tag, "p", [], %{column: 1, line: 1}}, - # {:text, "that", %{column_end: 14, line_end: 1}}, - # {::close, :tag, "p", %{column: 14, line: 1}}, - # {:eex_tag, "<% end %>", %{block?: false, column: 62, line: 2}}, - # {:text, "\n", %{column_end: 1, line_end: 2}}, - # {::close, :tag, "section", %{column: 1, line: 2}} - # ] - # - @eex_expr [:start_expr, :expr, :end_expr, :middle_expr] - - defp tokenize(source) do - {:ok, eex_nodes} = EEx.tokenize(source) - {tokens, cont} = Enum.reduce(eex_nodes, {[], {:text, :enabled}}, &do_tokenize(&1, &2, source)) - Tokenizer.finalize(tokens, "nofile", cont, source) + # Buffer processing callback for Parser - handles preserve mode propagation and text metadata + defp process_buffer([{:text, text, meta} | rest]) do + rest = may_set_preserve_on_block(rest, text) + + meta = + meta + |> Map.put_new(:newlines_before_text, count_newlines_before_text(text)) + |> Map.put_new(:newlines_after_text, count_newlines_after_text(text)) + + [{:text, text, meta} | rest] end - defp do_tokenize({:text, text, meta}, {tokens, cont}, source) do - text = List.to_string(text) - meta = [line: meta.line, column: meta.column] - state = Tokenizer.init(0, "nofile", source, Phoenix.LiveView.HTMLEngine) - Tokenizer.tokenize(text, meta, tokens, cont, state) + defp process_buffer([{:body_expr, _, _} = node | rest]) do + [node | set_preserve_on_block(rest)] end - defp do_tokenize({:comment, text, meta}, {tokens, cont}, _contents) do - {[{:eex_comment, List.to_string(text), meta} | tokens], cont} + defp process_buffer([{:eex, _, _} = node | rest]) do + [node | set_preserve_on_block(rest)] end - defp do_tokenize({type, opt, expr, %{column: column, line: line}}, {tokens, cont}, _contents) - when type in @eex_expr do - meta = %{opt: opt, line: line, column: column} - {[{:eex, type, expr |> List.to_string() |> String.trim(), meta} | tokens], cont} + defp process_buffer(buffer), do: buffer + + # In case the closing tag is immediately followed by non-whitespace text, + # we want to set mode as preserve. + defp may_set_preserve_on_block([{:block, type, name, attrs, block, meta} | rest], text) do + mode = + if String.trim_leading(text) != "" and :binary.first(text) not in ~c"\s\t\n\r" do + :preserve + else + Map.get(meta, :mode, :normal) + end + + [{:block, type, name, attrs, block, Map.put(meta, :mode, mode)} | rest] end - defp do_tokenize(_node, acc, _contents) do - acc + defp may_set_preserve_on_block(buffer, _text), do: buffer + + # Set preserve on block when it is immediately followed by interpolation. + defp set_preserve_on_block([{:block, type, name, attrs, block, meta} | rest]) do + [{:block, type, name, attrs, block, Map.put(meta, :mode, :preserve)} | rest] end - # Build an HTML Tree according to the tokens from the EEx and HTML tokenizers. - # - # This is a recursive algorithm that will build an HTML tree from a flat list of - # tokens. For instance, given this input: - # - # [ - # {:tag, "div", [], %{column: 1, line: 1}}, - # {:tag, "h1", [], %{column: 6, line: 1}}, - # {:text, "Hello", %{column_end: 15, line_end: 1}}, - # {::close, :tag, "h1", %{column: 15, line: 1}}, - # {::close, :tag, "div", %{column: 20, line: 1}}, - # {:tag, "div", [], %{column: 1, line: 2}}, - # {:tag, "h1", [], %{column: 6, line: 2}}, - # {:text, "World", %{column_end: 15, line_end: 2}}, - # {::close, :tag, "h1", %{column: 15, line: 2}}, - # {::close, :tag, "div", %{column: 20, line: 2}} - # ] - # - # The output will be: - # - # [ - # {:tag_block, "div", [], [{:tag_block, "h1", [], [text: "Hello"]}]}, - # {:tag_block, "div", [], [{:tag_block, "h1", [], [text: "World"]}]} - # ] - # - # Note that a `tag_block` has been created so that its fourth argument is a list of - # its nested content. - # - # ### How does this algorithm work? - # - # As this is a recursive algorithm, it starts with an empty buffer and an empty - # stack. The buffer will be accumulated until it finds a `{:tag, ..., ...}`. - # - # As soon as the `tag_open` arrives, a new buffer will be started and we move - # the previous buffer to the stack along with the `tag_open`: - # - # ``` - # defp build([{:tag, name, attrs, _meta} | tokens], buffer, stack) do - # build(tokens, [], [{name, attrs, buffer} | stack]) - # end - # ``` - # - # Then, we start to populate the buffer again until a `{::close, :tag, ...} arrives: - # - # ``` - # defp build([{::close, :tag, name, _meta} | tokens], buffer, [{name, attrs, upper_buffer} | stack]) do - # build(tokens, [{:tag_block, name, attrs, Enum.reverse(buffer)} | upper_buffer], stack) - # end - # ``` - # - # In the snippet above, we build the `tag_block` with the accumulated buffer, - # putting the buffer accumulated before the tag open (upper_buffer) on top. - # - # We apply the same logic for `eex` expressions but, instead of `tag_open` and - # `tag_close`, eex expressions use `start_expr`, `middle_expr` and `end_expr`. - # The only real difference is that also need to handle `middle_buffer`. - # - # So given this eex input: - # - # ```elixir - # [ - # {:eex, :start_expr, "if true do", %{column: 0, line: 0, opt: '='}}, - # {:text, "\n ", %{column_end: 3, line_end: 2}}, - # {:eex, :expr, "\"Hello\"", %{column: 3, line: 1, opt: '='}}, - # {:text, "\n", %{column_end: 1, line_end: 2}}, - # {:eex, :middle_expr, "else", %{column: 1, line: 2, opt: []}}, - # {:text, "\n ", %{column_end: 3, line_end: 2}}, - # {:eex, :expr, "\"World\"", %{column: 3, line: 3, opt: '='}}, - # {:text, "\n", %{column_end: 1, line_end: 2}}, - # {:eex, :end_expr, "end", %{column: 1, line: 4, opt: []}} - # ] - # ``` - # - # The output will be: - # - # ```elixir - # [ - # {:eex_block, "if true do", - # [ - # {[{:eex, "\"Hello\"", %{column: 3, line: 1, opt: '='}}], "else"}, - # {[{:eex, "\"World\"", %{column: 3, line: 3, opt: '='}}], "end"} - # ]} - # ] - # ``` - defp to_tree([], buffer, [], _opts) do - {:ok, Enum.reverse(buffer)} + defp set_preserve_on_block(buffer), do: buffer + + defp count_newlines_before_text(binary), + do: count_newlines_until_text(binary, 0, 0, 1) + + defp count_newlines_after_text(binary), + do: count_newlines_until_text(binary, 0, byte_size(binary) - 1, -1) + + defp count_newlines_until_text(binary, counter, pos, inc) do + try do + :binary.at(binary, pos) + rescue + _ -> counter + else + char when char in [?\s, ?\t] -> count_newlines_until_text(binary, counter, pos + inc, inc) + ?\n -> count_newlines_until_text(binary, counter + 1, pos + inc, inc) + _ -> counter + end end - defp to_tree([], _buffer, [{name, _, %{line: line, column: column}, _} | _], _opts) do - message = "end of template reached without closing tag for <#{name}>" - {:error, line, column, message} + # Tree transformation - augments Parser output with formatter metadata + defp transform_tree(nodes, source, newlines) do + state = %{source: {source, newlines}} + augment_nodes(nodes, state) end - defp to_tree([{:text, text, %{context: [:comment_start]}} | tokens], buffer, stack, opts) do - to_tree(tokens, [], [{:comment, text, buffer} | stack], opts) + # Augment nodes with formatter-specific metadata + defp augment_nodes(nodes, state) when is_list(nodes) do + nodes + |> reduce_html_comments([]) + |> Enum.map(&augment_node(&1, state)) end - defp to_tree( - [{:text, text, %{context: [:comment_end | _rest]}} | tokens], - buffer, - [{:comment, start_text, upper_buffer} | stack], - opts + # Group text nodes with :comment_start/:comment_end context into {:html_comment, block} + defp reduce_html_comments([], acc), do: Enum.reverse(acc) + + # Single node that is both comment start and end + defp reduce_html_comments( + [{:text, text, %{context: [:comment_start, :comment_end]}} | rest], + acc ) do meta = %{ newlines_before_text: count_newlines_before_text(text), newlines_after_text: count_newlines_after_text(text) } - buffer = Enum.reverse([{:text, String.trim_trailing(text), meta} | buffer]) - text = {:text, String.trim_leading(start_text), %{}} - to_tree(tokens, [{:html_comment, [text | buffer]} | upper_buffer], stack, opts) + comment = {:html_comment, [{:text, String.trim(text), meta}]} + reduce_html_comments(rest, [comment | acc]) end - defp to_tree( - [{:text, text, %{context: [:comment_start, :comment_end]}} | tokens], - buffer, - stack, - opts + # Comment start - begin accumulating comment content + defp reduce_html_comments( + [{:text, text, %{context: [:comment_start]}} | rest], + acc ) do - meta = %{ - newlines_before_text: count_newlines_before_text(text), - newlines_after_text: count_newlines_after_text(text) - } - - to_tree(tokens, [{:html_comment, [{:text, String.trim(text), meta}]} | buffer], stack, opts) + collect_comment(rest, [{:text, String.trim_leading(text), %{}}], acc) end - defp to_tree([{:text, text, _meta} | tokens], buffer, stack, opts) do - buffer = may_set_preserve_on_block(buffer, text) + # Regular node - pass through + defp reduce_html_comments([node | rest], acc) do + reduce_html_comments(rest, [node | acc]) + end + # Collect comment content until we hit comment_end + defp collect_comment( + [{:text, text, %{context: [:comment_end | _rest]}} | rest], + comment_buffer, + acc + ) do meta = %{ newlines_before_text: count_newlines_before_text(text), newlines_after_text: count_newlines_after_text(text) } - to_tree(tokens, [{:text, text, meta} | buffer], stack, opts) + end_text = {:text, String.trim_trailing(text), meta} + block = Enum.reverse([end_text | comment_buffer]) + comment = {:html_comment, block} + reduce_html_comments(rest, [comment | acc]) end - defp to_tree([{:body_expr, value, meta} | tokens], buffer, stack, opts) do - buffer = set_preserve_on_block(buffer) - to_tree(tokens, [{:body_expr, value, meta} | buffer], stack, opts) + defp collect_comment([node | rest], comment_buffer, acc) do + collect_comment(rest, [node | comment_buffer], acc) end - defp to_tree([{type, _name, attrs, %{closing: _} = meta} | tokens], buffer, stack, opts) - when is_tag_open(type) do - to_tree(tokens, [{:tag_self_close, meta.tag_name, attrs} | buffer], stack, opts) - end - - defp to_tree([{type, _name, attrs, meta} | tokens], buffer, stack, opts) - when is_tag_open(type) do - to_tree(tokens, [], [{meta.tag_name, attrs, meta, buffer} | stack], opts) - end + # Handle block tags - add mode and recursively augment children + defp augment_node({:block, type, name, attrs, children, meta}, state) do + tag_name = meta.tag_name + mode = determine_mode(tag_name, attrs, meta) - defp to_tree( - [{:close, _type, _name, close_meta} | tokens], - reversed_buffer, - [{tag_name, attrs, open_meta, upper_buffer} | stack], - opts - ) do - {mode, block} = - if tag_name in ["pre", "textarea"] or contains_special_attrs?(attrs) do + {children, meta} = + if mode == :preserve do content = - content_from_source(opts.source, open_meta.inner_location, close_meta.inner_location) + content_from_source(state.source, meta.inner_location, meta.close_inner_location) - {:preserve, [{:text, content, %{newlines_before_text: 0, newlines_after_text: 0}}]} + {[{:text, content, %{newlines_before_text: 0, newlines_after_text: 0}}], + Map.put(meta, :mode, :preserve)} else - {:normal, Enum.reverse(reversed_buffer)} + {augment_nodes(children, state), Map.put(meta, :mode, :normal)} end - tag_block = {:tag_block, tag_name, attrs, block, %{mode: mode}} - to_tree(tokens, [tag_block | upper_buffer], stack, opts) + {:block, type, name, attrs, children, meta} end - # handle eex + # Recursively augment eex_block children + defp augment_node({:eex_block, expr, blocks, meta}, state) do + blocks = + Enum.map(blocks, fn {children, clause, clause_meta} -> + {augment_nodes(children, state), clause, clause_meta} + end) - defp to_tree([{:eex_comment, text, _meta} | tokens], buffer, stack, opts) do - to_tree(tokens, [{:eex_comment, text} | buffer], stack, opts) + {:eex_block, expr, blocks, meta} end - defp to_tree([{:eex, :start_expr, expr, meta} | tokens], buffer, stack, opts) do - to_tree(tokens, [], [{:eex_block, expr, meta, buffer} | stack], opts) + # html_comment - recursively augment block content + defp augment_node({:html_comment, block}, state) do + {:html_comment, augment_nodes(block, state)} end - defp to_tree( - [{:eex, :middle_expr, middle_expr, _meta} | tokens], - buffer, - [{:eex_block, expr, meta, upper_buffer, middle_buffer} | stack], - opts - ) do - middle_buffer = [{Enum.reverse(buffer), middle_expr} | middle_buffer] - to_tree(tokens, [], [{:eex_block, expr, meta, upper_buffer, middle_buffer} | stack], opts) - end - - defp to_tree( - [{:eex, :middle_expr, middle_expr, _meta} | tokens], - buffer, - [{:eex_block, expr, meta, upper_buffer} | stack], - opts - ) do - middle_buffer = [{Enum.reverse(buffer), middle_expr}] - to_tree(tokens, [], [{:eex_block, expr, meta, upper_buffer, middle_buffer} | stack], opts) - end - - defp to_tree( - [{:eex, :end_expr, end_expr, _meta} | tokens], - buffer, - [{:eex_block, expr, meta, upper_buffer, middle_buffer} | stack], - opts - ) do - block = Enum.reverse([{Enum.reverse(buffer), end_expr} | middle_buffer]) - to_tree(tokens, [{:eex_block, expr, block, meta} | upper_buffer], stack, opts) - end - - defp to_tree( - [{:eex, :end_expr, end_expr, _meta} | tokens], - buffer, - [{:eex_block, expr, meta, upper_buffer} | stack], - opts - ) do - block = [{Enum.reverse(buffer), end_expr}] - to_tree(tokens, [{:eex_block, expr, block, meta} | upper_buffer], stack, opts) - end - - defp to_tree([{:eex, _type, expr, meta} | tokens], buffer, stack, opts) do - buffer = set_preserve_on_block(buffer) - to_tree(tokens, [{:eex, expr, meta} | buffer], stack, opts) - end - - # -- HELPERS - - defp count_newlines_before_text(binary), - do: count_newlines_until_text(binary, 0, 0, 1) + # Pass through other node types + defp augment_node(node, _state), do: node - defp count_newlines_after_text(binary), - do: count_newlines_until_text(binary, 0, byte_size(binary) - 1, -1) - - defp count_newlines_until_text(binary, counter, pos, inc) do - try do - :binary.at(binary, pos) - rescue - _ -> counter - else - char when char in [?\s, ?\t] -> count_newlines_until_text(binary, counter, pos + inc, inc) - ?\n -> count_newlines_until_text(binary, counter + 1, pos + inc, inc) - _ -> counter + # Determine mode based on tag name, attributes, and existing meta + defp determine_mode(tag_name, attrs, meta) do + cond do + Map.get(meta, :mode) == :preserve -> :preserve + tag_name in ["pre", "textarea"] -> :preserve + contains_special_attrs?(attrs) -> :preserve + true -> :normal end end - # In case the closing tag is immediatelly followed by non whitespace text, - # we want to set mode as preserve. - defp may_set_preserve_on_block([{:tag_block, name, attrs, block, meta} | list], text) do - mode = - if String.trim_leading(text) != "" and :binary.first(text) not in ~c"\s\t\n\r" do - :preserve - else - meta.mode - end - - [{:tag_block, name, attrs, block, %{meta | mode: mode}} | list] - end - - defp may_set_preserve_on_block(buffer, _text), do: buffer - - # Set preserve on block when it is immediately followed by interpolation. - defp set_preserve_on_block([{:tag_block, name, attrs, block, meta} | list]) do - [{:tag_block, name, attrs, block, %{meta | mode: :preserve}} | list] - end - - defp set_preserve_on_block(buffer), do: buffer - defp contains_special_attrs?(attrs) do Enum.any?(attrs, fn {"contenteditable", {:string, "false", _meta}, _} -> false @@ -665,6 +516,7 @@ defmodule Phoenix.LiveView.HTMLFormatter do end) end + # Extract content from source between two locations defp content_from_source( {source, newlines}, {line_start, column_start}, diff --git a/lib/phoenix_live_view/tag_compiler.ex b/lib/phoenix_live_view/tag_compiler.ex new file mode 100644 index 0000000000..12ec87c744 --- /dev/null +++ b/lib/phoenix_live_view/tag_compiler.ex @@ -0,0 +1,29 @@ +defmodule Phoenix.LiveView.TagCompiler do + @moduledoc """ + TODO + """ + + alias Phoenix.LiveView.TagCompiler + + @doc """ + Compiles a HEEx template into Elixir code. + + TODO + """ + def compile(source, opts \\ []) do + opts = + Keyword.merge( + [ + tag_handler: Phoenix.LiveView.HTMLEngine, + engine: Phoenix.LiveView.Engine, + source: source, + trim_eex: false + ], + opts + ) + + source + |> TagCompiler.Parser.parse!(opts) + |> TagCompiler.Compiler.compile(opts) + end +end diff --git a/lib/phoenix_live_view/tag_compiler/parser.ex b/lib/phoenix_live_view/tag_compiler/parser.ex new file mode 100644 index 0000000000..941f7db8d8 --- /dev/null +++ b/lib/phoenix_live_view/tag_compiler/parser.ex @@ -0,0 +1,437 @@ +defmodule Phoenix.LiveView.TagCompiler.Parser do + @moduledoc false + + alias Phoenix.LiveView.TagCompiler.Tokenizer + + defguardp is_tag_open(tag_type) when tag_type not in [:close, :eex] + + def parse(source, opts \\ []) do + tag_handler = Keyword.fetch!(opts, :tag_handler) + caller = Keyword.get(opts, :caller) + prune_text_after_slots = Keyword.get(opts, :prune_text_after_slots, true) + process_buffer = Keyword.get(opts, :process_buffer) + + source + |> tokenize(opts) + |> to_tree([], [], %{ + tag_handler: tag_handler, + caller: caller, + prune_text_after_slots: prune_text_after_slots, + process_buffer: process_buffer + }) + catch + {:syntax_error, line, column, message} -> + {:error, line, column, message} + end + + def parse!(source, opts \\ []) do + case parse(source, opts) do + {:ok, nodes} -> + nodes + + {:error, line, column, message} -> + raise Tokenizer.ParseError, + line: line, + column: column, + file: opts[:file] || "nofile", + description: + message <> + Tokenizer.ParseError.code_snippet( + source, + %{line: line, column: column}, + opts[:indentation] || 0 + ) + end + end + + # Tokenize contents using EEx.tokenize and Phoenix.LiveView.TagCompiler.Tokenizer respectively. + # + # The following content: + # + # "
\n

<%= user.name %>

\n <%= if true do %>

this

<% else %>

that

<% end %>\n
\n" + # + # Will be tokenized as: + # + # [ + # {:tag, "section", [], %{column: 1, line: 1}}, + # {:text, "\n ", %{column_end: 3, line_end: 2}}, + # {:tag, "p", [], %{column: 3, line: 2}}, + # {:eex, :start_expr, "<%= user.name >

\n <%= if true do %>", %{block?: true, column: 6, line: 1}}, + # {:text, " ", %{column_end: 2, line_end: 1}}, + # {:tag, "p", [], %{column: 2, line: 1}}, + # {:text, "this", %{column_end: 12, line_end: 1}}, + # {:close, :tag, "p", %{column: 12, line: 1}}, + # {:eex, :middle_expr, "<% else %>", %{block?: false, column: 35, line: 2}}, + # {:tag, "p", [], %{column: 1, line: 1}}, + # {:text, "that", %{column_end: 14, line_end: 1}}, + # {:close, :tag, "p", %{column: 14, line: 1}}, + # {:eex, :end_expr, "<% end %>", %{block?: false, column: 62, line: 2}}, + # {:text, "\n", %{column_end: 1, line_end: 2}}, + # {:close, :tag, "section", %{column: 1, line: 2}} + # ] + # + @eex_expr [:start_expr, :expr, :end_expr, :middle_expr] + + @doc false + def tokenize(source, opts) do + file = Keyword.get(opts, :file, "nofile") + indentation = Keyword.get(opts, :indentation, 0) + trim_eex = Keyword.get(opts, :trim_eex, true) + {:ok, eex_nodes} = EEx.tokenize(source, opts) + + {tokens, cont} = + Enum.reduce( + eex_nodes, + {[], {:text, :enabled}}, + &do_tokenize(&1, &2, source, %{file: file, indentation: indentation, trim_eex: trim_eex}) + ) + + Tokenizer.finalize(tokens, file, cont, source) + end + + defp do_tokenize({:text, text, meta}, {tokens, cont}, source, %{ + file: file, + indentation: indentation + }) do + text = List.to_string(text) + meta = [line: meta.line, column: meta.column] + state = Tokenizer.init(indentation, file, source, Phoenix.LiveView.HTMLEngine) + Tokenizer.tokenize(text, meta, tokens, cont, state) + end + + defp do_tokenize({:comment, text, meta}, {tokens, cont}, _contents, _opts) do + {[{:eex_comment, List.to_string(text), meta} | tokens], cont} + end + + defp do_tokenize( + {type, opt, expr, %{column: column, line: line}}, + {tokens, cont}, + _contents, + opts + ) + when type in @eex_expr do + meta = %{opt: opt, line: line, column: column} + + {[{:eex, type, expr |> List.to_string() |> maybe_trim_eex(opts.trim_eex), meta} | tokens], + cont} + end + + defp do_tokenize(_node, acc, _contents, _opts) do + acc + end + + defp maybe_trim_eex(string, true), do: String.trim(string) + defp maybe_trim_eex(string, _), do: string + + # Build an HTML Tree according to the tokens from the EEx and HTML tokenizers. + # + # This is a recursive algorithm that will build an HTML tree from a flat list of + # tokens. For instance, given this input: + # + # [ + # {:tag, "div", [], %{column: 1, line: 1}}, + # {:tag, "h1", [], %{column: 6, line: 1}}, + # {:text, "Hello", %{column_end: 15, line_end: 1}}, + # {:close, :tag, "h1", %{column: 15, line: 1}}, + # {:close, :tag, "div", %{column: 20, line: 1}}, + # {:tag, "div", [], %{column: 1, line: 2}}, + # {:tag, "h1", [], %{column: 6, line: 2}}, + # {:text, "World", %{column_end: 15, line_end: 2}}, + # {:close, :tag, "h1", %{column: 15, line: 2}}, + # {:close, :tag, "div", %{column: 20, line: 2}} + # ] + # + # The output will be: + # + # [ + # {:tag_block, "div", [], [{:tag_block, "h1", [], [text: "Hello"]}]}, + # {:tag_block, "div", [], [{:tag_block, "h1", [], [text: "World"]}]} + # ] + # + # Note that a `tag_block` has been created so that its fourth argument is a list of + # its nested content. + # + # ### How does this algorithm work? + # + # As this is a recursive algorithm, it starts with an empty buffer and an empty + # stack. The buffer will be accumulated until it finds a `{:tag, ..., ...}`. + # + # As soon as the `tag_open` arrives, a new buffer will be started and we move + # the previous buffer to the stack along with the `tag_open`: + # + # ``` + # defp build([{:tag, name, attrs, _meta} | tokens], buffer, stack) do + # build(tokens, [], [{name, attrs, buffer} | stack]) + # end + # ``` + # + # Then, we start to populate the buffer again until a `{:close, :tag, ...} arrives: + # + # ``` + # defp build([{:close, :tag, name, _meta} | tokens], buffer, [{name, attrs, open_meta, upper_buffer} | stack]) do + # build(tokens, [{:block, :tag, name, attrs, Enum.reverse(buffer), open_meta} | upper_buffer], stack) + # end + # ``` + # + # In the snippet above, we build the `tag_block` with the accumulated buffer, + # putting the buffer accumulated before the tag open (upper_buffer) on top. + # + # We apply the same logic for `eex` expressions but, instead of `tag_open` and + # `tag_close`, eex expressions use `start_expr`, `middle_expr` and `end_expr`. + # The only real difference is that also need to handle `middle_buffer`. + # + # So given this eex input: + # + # ```elixir + # [ + # {:eex, :start_expr, "if true do", %{column: 0, line: 0, opt: '='}}, + # {:text, "\n ", %{column_end: 3, line_end: 2}}, + # {:eex, :expr, "\"Hello\"", %{column: 3, line: 1, opt: '='}}, + # {:text, "\n", %{column_end: 1, line_end: 2}}, + # {:eex, :middle_expr, "else", %{column: 1, line: 2, opt: []}}, + # {:text, "\n ", %{column_end: 3, line_end: 2}}, + # {:eex, :expr, "\"World\"", %{column: 3, line: 3, opt: '='}}, + # {:text, "\n", %{column_end: 1, line_end: 2}}, + # {:eex, :end_expr, "end", %{column: 1, line: 4, opt: []}} + # ] + # ``` + # + # The output will be: + # + # ```elixir + # [ + # {:eex_block, "if true do", + # [ + # {[{:eex, "\"Hello\"", %{column: 3, line: 1, opt: '='}}], "else"}, + # {[{:eex, "\"World\"", %{column: 3, line: 3, opt: '='}}], "end"} + # ]} + # ] + # ``` + defp to_tree([], buffer, [], _state) do + {:ok, Enum.reverse(buffer)} + end + + defp to_tree( + [], + _buffer, + [{_type, _name, _, %{line: line, column: column} = meta, _} | _], + _state + ) do + message = "end of template reached without closing tag for <#{meta.tag_name}>" + {:error, line, column, message} + end + + defp to_tree([{:text, text, meta} | tokens], buffer, stack, state) do + # Preserve context for HTML comment handling in formatter + text_meta = Map.take(meta, [:context]) + buffer = process_buffer([{:text, text, text_meta} | buffer], state) + to_tree(tokens, buffer, stack, state) + end + + defp to_tree([{:body_expr, value, meta} | tokens], buffer, stack, state) do + buffer = process_buffer([{:body_expr, value, meta} | buffer], state) + to_tree(tokens, buffer, stack, state) + end + + # Self-closing slot - valid only as direct child of component + defp to_tree( + [{:slot, name, attrs, %{closing: _} = meta} | tokens], + buffer, + [{parent_type, _, _, _, _} | _] = stack, + state + ) + when parent_type in [:local_component, :remote_component] do + tokens = if state.prune_text_after_slots, do: prune_text(tokens), else: tokens + to_tree(tokens, [{:self_close, :slot, name, attrs, meta} | buffer], stack, state) + end + + # Self-closing slot - invalid context (not direct child of component) + defp to_tree( + [{:slot, name, _attrs, %{closing: _} = meta} | _tokens], + _buffer, + _stack, + _state + ) do + %{line: line, column: column} = meta + message = "invalid slot entry <:#{name}>. A slot entry must be a direct child of a component" + {:error, line, column, message} + end + + # Opening slot - valid only as direct child of component + defp to_tree( + [{:slot, name, attrs, meta} | tokens], + buffer, + [{parent_type, _, _, _, _} | _] = stack, + state + ) + when parent_type in [:local_component, :remote_component] do + to_tree(tokens, [], [{:slot, name, attrs, meta, buffer} | stack], state) + end + + # Opening slot - invalid context (not direct child of component) + defp to_tree([{:slot, name, _attrs, meta} | _tokens], _buffer, _stack, _state) do + %{line: line, column: column} = meta + message = "invalid slot entry <:#{name}>. A slot entry must be a direct child of a component" + {:error, line, column, message} + end + + # Closing a slot + defp to_tree( + [{:close, :slot, _name, close_meta} | tokens], + reversed_buffer, + [{:slot, tag_name, attrs, open_meta, upper_buffer} | stack], + state + ) do + block = Enum.reverse(reversed_buffer) + open_meta = Map.put(open_meta, :close_inner_location, close_meta.inner_location) + tag_block = {:block, :slot, tag_name, attrs, block, open_meta} + tokens = if state.prune_text_after_slots, do: prune_text(tokens), else: tokens + to_tree(tokens, [tag_block | upper_buffer], stack, state) + end + + # Self-closing tag or component + defp to_tree([{type, name, attrs, %{closing: _} = meta} | tokens], buffer, stack, state) + when is_tag_open(type) do + to_tree(tokens, [{:self_close, type, name, attrs, meta} | buffer], stack, state) + end + + # Opening tag or component + defp to_tree([{type, name, attrs, meta} | tokens], buffer, stack, state) + when is_tag_open(type) do + to_tree(tokens, [], [{type, name, attrs, meta, buffer} | stack], state) + end + + # Matching close tag + defp to_tree( + [{:close, _type, name, close_meta} | tokens], + reversed_buffer, + [{type, name, attrs, open_meta, upper_buffer} | stack], + state + ) do + block = Enum.reverse(reversed_buffer) + # Preserve close tag's inner_location for preserve mode content extraction + open_meta = Map.put(open_meta, :close_inner_location, close_meta.inner_location) + to_tree(tokens, [{:block, type, name, attrs, block, open_meta} | upper_buffer], stack, state) + end + + # Mismatched close tag + defp to_tree( + [{:close, _close_type, close_name, close_meta} | _tokens], + _buffer, + [{_open_type, open_name, _attrs, open_meta, _upper_buffer} | _stack], + state + ) do + %{line: line, column: column} = close_meta + void_note = void_tag_note(close_name, state) + + message = + "unmatched closing tag. Expected for <#{open_name}> at line #{open_meta.line}, got: #{void_note}" + + {:error, line, column, message} + end + + # Orphaned close tag - no matching open tag on stack + defp to_tree([{:close, _type, name, meta} | _tokens], _buffer, [], state) do + %{line: line, column: column} = meta + void_note = void_tag_note(name, state) + message = "missing opening tag for #{void_note}" + {:error, line, column, message} + end + + # EEx + + defp to_tree([{:eex_comment, text, _meta} | tokens], buffer, stack, state) do + to_tree(tokens, [{:eex_comment, text} | buffer], stack, state) + end + + defp to_tree([{:eex, :start_expr, expr, meta} | tokens], buffer, stack, state) do + to_tree(tokens, [], [{:eex_block, expr, meta, buffer} | stack], state) + end + + defp to_tree( + [{:eex, :middle_expr, middle_expr, middle_meta} | tokens], + buffer, + [{:eex_block, expr, meta, upper_buffer, middle_buffer} | stack], + state + ) do + middle_buffer = [{Enum.reverse(buffer), middle_expr, middle_meta} | middle_buffer] + + to_tree( + tokens, + [], + [{:eex_block, expr, meta, upper_buffer, middle_buffer} | stack], + state + ) + end + + defp to_tree( + [{:eex, :middle_expr, middle_expr, middle_meta} | tokens], + buffer, + [{:eex_block, expr, meta, upper_buffer} | stack], + state + ) do + middle_buffer = [{Enum.reverse(buffer), middle_expr, middle_meta}] + + to_tree( + tokens, + [], + [{:eex_block, expr, meta, upper_buffer, middle_buffer} | stack], + state + ) + end + + defp to_tree( + [{:eex, :end_expr, end_expr, end_meta} | tokens], + buffer, + [{:eex_block, expr, meta, upper_buffer, middle_buffer} | stack], + state + ) do + block = Enum.reverse([{Enum.reverse(buffer), end_expr, end_meta} | middle_buffer]) + to_tree(tokens, [{:eex_block, expr, block, meta} | upper_buffer], stack, state) + end + + defp to_tree( + [{:eex, :end_expr, end_expr, end_meta} | tokens], + buffer, + [{:eex_block, expr, meta, upper_buffer} | stack], + state + ) do + block = [{Enum.reverse(buffer), end_expr, end_meta}] + to_tree(tokens, [{:eex_block, expr, block, meta} | upper_buffer], stack, state) + end + + # end_expr reached but unclosed tag on stack (inside a do-block) + defp to_tree( + [{:eex, :end_expr, _end_expr, _end_meta} | _tokens], + _buffer, + [{_type, _name, _attrs, %{line: line, column: column} = meta, _upper_buffer} | _stack], + _state + ) do + message = "end of do-block reached without closing tag for <#{meta.tag_name}>" + {:error, line, column, message} + end + + defp to_tree([{:eex, _type, expr, meta} | tokens], buffer, stack, state) do + buffer = process_buffer([{:eex, expr, meta} | buffer], state) + to_tree(tokens, buffer, stack, state) + end + + # Prune leading whitespace from the next text token (used after slots) + defp prune_text([{:text, text, meta} | tokens]) do + [{:text, String.trim_leading(text), meta} | tokens] + end + + defp prune_text(tokens), do: tokens + + # Allow callers to hook into buffer processing (used by formatter for preserve mode propagation) + defp process_buffer(buffer, %{process_buffer: fun}) when is_function(fun), do: fun.(buffer) + defp process_buffer(buffer, _state), do: buffer + + defp void_tag_note(name, state) do + if state.tag_handler.void?(name) do + " (note <#{name}> is a void tag and cannot have any content)" + else + "" + end + end +end diff --git a/lib/phoenix_live_view/tokenizer.ex b/lib/phoenix_live_view/tag_compiler/tokenizer.ex similarity index 99% rename from lib/phoenix_live_view/tokenizer.ex rename to lib/phoenix_live_view/tag_compiler/tokenizer.ex index 93c9f0f842..c42ca47144 100644 --- a/lib/phoenix_live_view/tokenizer.ex +++ b/lib/phoenix_live_view/tag_compiler/tokenizer.ex @@ -1,4 +1,4 @@ -defmodule Phoenix.LiveView.Tokenizer do +defmodule Phoenix.LiveView.TagCompiler.Tokenizer do @moduledoc false @space_chars ~c"\s\t\f" @quote_chars ~c"\"'" diff --git a/lib/phoenix_live_view/tag_engine.ex b/lib/phoenix_live_view/tag_engine.ex index 8e4d48d4a3..1ff54ec927 100644 --- a/lib/phoenix_live_view/tag_engine.ex +++ b/lib/phoenix_live_view/tag_engine.ex @@ -214,8 +214,8 @@ defmodule Phoenix.LiveView.TagEngine do end end - alias Phoenix.LiveView.Tokenizer - alias Phoenix.LiveView.Tokenizer.ParseError + alias Phoenix.LiveView.TagCompiler.Tokenizer + alias Phoenix.LiveView.TagCompiler.Tokenizer.ParseError @behaviour EEx.Engine diff --git a/test/phoenix_component/macro_component_integration_test.exs b/test/phoenix_component/macro_component_integration_test.exs index 4fc6e5873e..93b02c5d37 100644 --- a/test/phoenix_component/macro_component_integration_test.exs +++ b/test/phoenix_component/macro_component_integration_test.exs @@ -8,6 +8,7 @@ defmodule Phoenix.Component.MacroComponentIntegrationTest do alias Phoenix.LiveViewTest.TreeDOM alias Phoenix.Component.MacroComponent + alias Phoenix.LiveView.TagCompiler.Tokenizer.ParseError defmodule MyComponent do @behaviour Phoenix.Component.MacroComponent @@ -120,7 +121,7 @@ defmodule Phoenix.Component.MacroComponentIntegrationTest do end test "raises when there is EEx inside" do - assert_raise Phoenix.LiveView.Tokenizer.ParseError, + assert_raise ParseError, ~r/EEx is not currently supported in macro components/, fn -> defmodule TestComponentUnsupportedEEx do @@ -140,7 +141,7 @@ defmodule Phoenix.Component.MacroComponentIntegrationTest do end test "raises when there is interpolation inside" do - assert_raise Phoenix.LiveView.Tokenizer.ParseError, + assert_raise ParseError, ~r/interpolation is not currently supported in macro components/, fn -> defmodule TestComponentUnsupportedInterpolation do @@ -158,7 +159,7 @@ defmodule Phoenix.Component.MacroComponentIntegrationTest do end test "raises when there are components inside" do - assert_raise Phoenix.LiveView.Tokenizer.ParseError, + assert_raise ParseError, ~r/function components cannot be nested inside a macro component/, fn -> defmodule TestComponentUnsupportedComponents do @@ -176,7 +177,7 @@ defmodule Phoenix.Component.MacroComponentIntegrationTest do end test "raises when trying to use :type on a component" do - assert_raise Phoenix.LiveView.Tokenizer.ParseError, + assert_raise ParseError, ~r/unsupported attribute \":type\"/, fn -> defmodule TestUnsupportedComponent do @@ -190,7 +191,7 @@ defmodule Phoenix.Component.MacroComponentIntegrationTest do end end - assert_raise Phoenix.LiveView.Tokenizer.ParseError, + assert_raise ParseError, ~r/unsupported attribute \":type\"/, fn -> defmodule TestUnsupportedComponent do @@ -208,7 +209,7 @@ defmodule Phoenix.Component.MacroComponentIntegrationTest do end test "raises for dynamic attributes" do - assert_raise Phoenix.LiveView.Tokenizer.ParseError, + assert_raise ParseError, ~r/dynamic attributes are not supported in macro components, got: @bar/, fn -> defmodule TestComponentUnsupportedDynamicAttributes1 do @@ -222,7 +223,7 @@ defmodule Phoenix.Component.MacroComponentIntegrationTest do end end - assert_raise Phoenix.LiveView.Tokenizer.ParseError, + assert_raise ParseError, ~r/dynamic attributes are not supported in macro components, got: @bar/, fn -> defmodule TestComponentUnsupportedDynamicAttributes2 do diff --git a/test/phoenix_live_view/colocated_hook_test.exs b/test/phoenix_live_view/colocated_hook_test.exs index 08892f3a35..9f99ad40d9 100644 --- a/test/phoenix_live_view/colocated_hook_test.exs +++ b/test/phoenix_live_view/colocated_hook_test.exs @@ -63,7 +63,7 @@ defmodule Phoenix.LiveView.ColocatedHookTest do end test "raises for invalid name" do - assert_raise Phoenix.LiveView.Tokenizer.ParseError, + assert_raise Phoenix.LiveView.TagCompiler.Tokenizer.ParseError, ~r/the name attribute of a colocated hook must be a compile-time string\. Got: @foo/, fn -> defmodule TestComponentInvalidName do diff --git a/test/phoenix_live_view/colocated_js_test.exs b/test/phoenix_live_view/colocated_js_test.exs index cd580ae853..84d8697c21 100644 --- a/test/phoenix_live_view/colocated_js_test.exs +++ b/test/phoenix_live_view/colocated_js_test.exs @@ -194,7 +194,7 @@ defmodule Phoenix.LiveView.ColocatedJSTest do end test "raises for invalid name" do - assert_raise Phoenix.LiveView.Tokenizer.ParseError, + assert_raise Phoenix.LiveView.TagCompiler.Tokenizer.ParseError, ~r/the name attribute of a colocated script must be a compile-time string\. Got: @foo/, fn -> defmodule TestComponentInvalidName do diff --git a/test/phoenix_live_view/tokenizer_test.exs b/test/phoenix_live_view/heex/tokenizer_test.exs similarity index 99% rename from test/phoenix_live_view/tokenizer_test.exs rename to test/phoenix_live_view/heex/tokenizer_test.exs index 1e88f87b29..58805d5a8d 100644 --- a/test/phoenix_live_view/tokenizer_test.exs +++ b/test/phoenix_live_view/heex/tokenizer_test.exs @@ -1,7 +1,7 @@ -defmodule Phoenix.LiveView.TokenizerTest do +defmodule Phoenix.LiveView.TagCompiler.TokenizerTest do use ExUnit.Case, async: true - alias Phoenix.LiveView.Tokenizer.ParseError - alias Phoenix.LiveView.Tokenizer + alias Phoenix.LiveView.TagCompiler.Tokenizer.ParseError + alias Phoenix.LiveView.TagCompiler.Tokenizer defp tokenizer_state(text), do: Tokenizer.init(0, "nofile", text, Phoenix.LiveView.HTMLEngine) diff --git a/test/phoenix_live_view/html_engine_test.exs b/test/phoenix_live_view/html_engine_test.exs index 839c96a357..cf30ef95c9 100644 --- a/test/phoenix_live_view/html_engine_test.exs +++ b/test/phoenix_live_view/html_engine_test.exs @@ -5,7 +5,7 @@ defmodule Phoenix.LiveView.HTMLEngineTest do import Phoenix.Component - alias Phoenix.LiveView.Tokenizer.ParseError + alias Phoenix.LiveView.TagCompiler.Tokenizer.ParseError defp eval(string, assigns \\ %{}, opts \\ []) do {env, opts} = Keyword.pop(opts, :env, __ENV__) diff --git a/test/phoenix_live_view/html_formatter_test.exs b/test/phoenix_live_view/html_formatter_test.exs index 1a66e7084e..aeecb54f53 100644 --- a/test/phoenix_live_view/html_formatter_test.exs +++ b/test/phoenix_live_view/html_formatter_test.exs @@ -26,7 +26,7 @@ defmodule Phoenix.LiveView.HTMLFormatterTest do end test "errors on invalid HTML" do - assert_raise Phoenix.LiveView.Tokenizer.ParseError, + assert_raise Phoenix.LiveView.TagCompiler.Tokenizer.ParseError, ~r/end of template reached without closing tag for