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 #{open_name}> for <#{open_name}> at line #{open_meta.line}, got: #{close_name}>#{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 #{name}>#{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