diff --git a/config/test.exs b/config/test.exs
index 48793de45e..7b33aa6c66 100644
--- a/config/test.exs
+++ b/config/test.exs
@@ -4,3 +4,8 @@ config :logger, :level, :debug
config :logger, :default_handler, false
config :phoenix_live_view, enable_expensive_runtime_checks: true
+
+# Disable :root_tag_attribute so the majority of the tests
+# are not polluted by it. It will be explicitly re-enabled for
+# tests related to :root_tag_attribute functionality.
+config :phoenix_live_view, :root_tag_attribute, nil
diff --git a/lib/phoenix_component/macro_component.ex b/lib/phoenix_component/macro_component.ex
index 5c93cb00c8..d44bc23e0f 100644
--- a/lib/phoenix_component/macro_component.ex
+++ b/lib/phoenix_component/macro_component.ex
@@ -120,6 +120,131 @@ defmodule Phoenix.Component.MacroComponent do
# LiveView's end to end tests: a macro component that performs
# [syntax highlighting at compile time](https://github.com/phoenixframework/phoenix_live_view/blob/38851d943f3280c5982d75679291dccb8c442534/test/e2e/support/colocated_live.ex#L4-L35)
# using the [Makeup](https://hexdocs.pm/makeup/Makeup.html) library.
+ #
+ # ## Root tag attributes
+ #
+ # HEEx templates support adding a root tag attributes to the rendered page.
+ # These can be useful for debugging or as selectors for things such as CSS.
+ #
+ # Note that root tag attributes are applied to all root tags in the given
+ # template, not just the outermost root tags. This means that tags at the root
+ # of the template itself, at the root of any component inner blocks, or at
+ # the root of any component slots will all have root tag attributes applied.
+ #
+ # For example, imagine the following component definition:
+ #
+ # ```elixir
+ # defmodule MyAppWeb.MyModule do
+ # slot :inner_block, required: true
+ # slot :named_slot, required: true
+ #
+ # def my_function(assigns) do
+ # ~H"""
+ #
+ #
+ # {render_slot(@inner_block)}
+ #
+ #
+ #
+ # """
+ # end
+ # end
+ # ```
+ #
+ # And the following HEEx template:
+ #
+ # ```heex
+ #
+ #
+ # <.my_function>
+ #
+ #
+ # Inner Block
+ #
+ #
+ # <:named_slot>
+ #
+ #
+ # Named Slot
+ #
+ #
+ #
+ #
+ #
+ #
+ # ```
+ #
+ # By setting the global `root_tag_attribute` to "phx-r", the rendered HTML would look as follows:
+ #
+ # ```html
+ #
+ #
+ #
+ #
+ #
+ #
+ # Inner Block
+ #
+ #
+ #
+ #
+ #
+ #
+ #
+ # ```
+ #
+ # This feature works on any `~H` or `.html.heex` template. They can be enabled
+ # globally with the following configuration in your `config/config.exs` file:
+ #
+ # config :phoenix_live_view, root_tag_attribute: "phx-r"
+ #
+ # Changing this configuration will require `mix clean` and a full recompile.
+ #
+ # Additional root tag attributes can also be applied by MacroComponents. See the `Directives` section
+ # below for details.
+ #
+ # ## Directives
+ #
+ # Macro components may return directives from the module's optional `c:directives/2` callback
+ # which can be used to influence other elements in the template outside of the macro component at compile-time.
+ # Macro components which specify directives **must** be placed at the beginning of the template in which they are used.
+ # For example:
+ #
+ # ```elixir
+ # defmodule MyAppWeb.TagRootSampleComponent do
+ # @behaviour Phoenix.Component.MacroComponent
+ #
+ # @impl true
+ # def directives(_ast, _meta) do
+ # {:ok, [root_tag_attribute: {"phx-sample-one", "test"}, root_tag_attribute: {"phx-sample-two", "test"}]}
+ # end
+ #
+ # @impl true
+ # def transform(_ast, _meta) do
+ # {:ok, "", %{}}
+ # end
+ # end
+ # ```
+ # The following directives are currently supported:
+ #
+ # ### Options
+ #
+ # * `:root_tag_attribute` - `{name, value}` pair to apply as an attribute to all root tags during template compilation.
+ # Requires that a global `:root_tag_attribute` be configured for the application. `name` must be a compile-time string. `value` must be a
+ # compile-time string or `true`. May be provided multiple times to apply multiple attributes. Requires that a global `:root_tag_attribute` be configured.
+ # See the `Root tag attributes` section above for more details.
@type tag :: binary()
@type attribute :: {binary(), Macro.t()}
@@ -128,6 +253,13 @@ defmodule Phoenix.Component.MacroComponent do
@type tag_meta :: %{closing: :self | :void}
@type heex_ast :: {tag(), attributes(), children(), tag_meta()} | binary()
@type transform_meta :: %{env: Macro.Env.t()}
+ @type directives_meta :: %{env: Macro.Env.t()}
+ @type directive :: {:root_tag_attribute, {name :: String.t(), value :: String.t() | true}}
+ @type directives :: [directive]
+
+ @optional_callbacks [directives: 2]
+
+ @callback directives(heex_ast :: heex_ast(), meta :: directives_meta()) :: {:ok, directives()}
@callback transform(heex_ast :: heex_ast(), meta :: transform_meta()) ::
{:ok, heex_ast()} | {:ok, heex_ast(), data :: term()}
diff --git a/lib/phoenix_live_view/tag_engine.ex b/lib/phoenix_live_view/tag_engine.ex
index 8c89d03e0d..7585105b4d 100644
--- a/lib/phoenix_live_view/tag_engine.ex
+++ b/lib/phoenix_live_view/tag_engine.ex
@@ -20,6 +20,8 @@ defmodule Phoenix.LiveView.TagEngine do
Where `:tag_handler` implements the behaviour defined by this module.
"""
+ @default_root_tag_attribute "phx-r"
+
@doc """
Classify the tag type from the given binary.
@@ -205,7 +207,9 @@ defmodule Phoenix.LiveView.TagEngine do
indentation: Keyword.get(opts, :indentation, 0),
caller: Keyword.fetch!(opts, :caller),
source: Keyword.fetch!(opts, :source),
- tag_handler: tag_handler
+ tag_handler: tag_handler,
+ root_tag_attributes: [],
+ collecting_directives?: true
}
end
@@ -283,7 +287,9 @@ defmodule Phoenix.LiveView.TagEngine do
caller: caller,
source: source,
indentation: indentation,
- tag_handler: tag_handler
+ tag_handler: tag_handler,
+ root_tag_attributes: root_tag_attributes,
+ collecting_directives?: collecting_directives?
},
root
) do
@@ -298,7 +304,9 @@ defmodule Phoenix.LiveView.TagEngine do
caller: caller,
root: root,
indentation: indentation,
- tag_handler: tag_handler
+ tag_handler: tag_handler,
+ root_tag_attributes: root_tag_attributes,
+ collecting_directives?: collecting_directives?
}
end
@@ -310,7 +318,7 @@ defmodule Phoenix.LiveView.TagEngine do
@impl true
def handle_begin(state) do
- update_subengine(%{state | tokens: []}, :handle_begin, [])
+ update_subengine(%{state | tokens: [], collecting_directives?: false}, :handle_begin, [])
end
@impl true
@@ -319,6 +327,8 @@ defmodule Phoenix.LiveView.TagEngine do
tokenizer_state = Tokenizer.init(indentation, file, source, state.tag_handler)
{tokens, cont} = Tokenizer.tokenize(text, meta, tokens, cont, tokenizer_state)
+ state = collect_directives(state, Enum.reverse(tokens))
+
%{
state
| tokens: tokens,
@@ -327,6 +337,125 @@ defmodule Phoenix.LiveView.TagEngine do
}
end
+ defp collect_directives(state, tokens) do
+ # Allow for leading whitespace
+ stripped_tokens = Tokenizer.strip_text_token_fully(tokens)
+
+ case stripped_tokens do
+ [] ->
+ state
+
+ [{:tag, name, _attrs, tag_meta} = token | rest] ->
+ case check_and_validate_macro_component(token, state) do
+ {:macro_component, module, attrs} ->
+ # Use build_ast to consume all of the tokens in this MacroComponent
+ try do
+ {ast, rest} =
+ case Phoenix.Component.MacroComponent.build_ast(
+ [{:tag, name, attrs, tag_meta} | rest],
+ state.caller
+ ) do
+ {:ok, ast, rest} -> {ast, rest}
+ {:error, message, meta} -> raise_syntax_error!(message, meta, state)
+ end
+
+ cond do
+ state.collecting_directives? and function_exported?(module, :directives, 2) ->
+ case module.directives(ast, %{env: state.caller}) do
+ {:ok, directives} when is_list(directives) ->
+ state =
+ Enum.reduce(directives, state, fn directive, state ->
+ apply_macro_component_directive!(directive, module, tag_meta, state)
+ end)
+
+ collect_directives(state, rest)
+
+ other ->
+ raise ArgumentError,
+ "a macro component must return {:ok, directives}, got: #{inspect(other)}"
+ end
+
+ not state.collecting_directives? and function_exported?(module, :directives, 2) ->
+ raise_syntax_error!(
+ "macro component #{module} specified directives and therefore must appear at the very beginning of the template",
+ tag_meta,
+ state
+ )
+
+ true ->
+ collect_directives(state, rest)
+ end
+ rescue
+ e in ArgumentError ->
+ raise_syntax_error!(
+ Exception.message(e),
+ tag_meta,
+ state
+ )
+ end
+
+ :tag ->
+ collect_directives(%{state | collecting_directives?: false}, rest)
+ end
+
+ [_ | rest] ->
+ collect_directives(%{state | collecting_directives?: false}, rest)
+ end
+ end
+
+ defp apply_macro_component_directive!({:root_tag_attribute, attribute}, module, tag_meta, state) do
+ case get_root_tag_attribute() do
+ root_tag_attribute when is_binary(root_tag_attribute) ->
+ :ok
+
+ root_tag_attribute ->
+ message = """
+ a global :root_tag_attribute must be configured for macro components to use the :root_tag_attribute directive
+
+ Macro Component: #{module}
+
+ Expected global :root_tag_attribute to be a string, got: #{inspect(root_tag_attribute)}
+
+ The global :root_tag_attribute defaults to "#{@default_root_tag_attribute}". If you are getting this warning, the global
+ :root_tag_attribute has been explicitly disabled in your application's config. You will be unable
+ to use this macro component until the global `:root_tag_attribute` is re-enabled.
+
+ If you wish to use a global :root_tag_attribute other than "#{@default_root_tag_attribute}", you can configure a
+ custom attribute like so:
+
+ config :phoenix_live_view, root_tag_attribute: "your-attribute-here"
+ """
+
+ raise_syntax_error!(message, tag_meta, state)
+ end
+
+ case attribute do
+ {name, value} when is_binary(name) and (is_binary(value) or value == true) ->
+ %{state | root_tag_attributes: [{name, value} | state.root_tag_attributes]}
+
+ attribute ->
+ message = """
+ expected {name, value} for :root_tag_attribute directive from macro component #{module}, got: #{inspect(attribute)}
+
+ name must be a compile-time string, and value must be a compile-time string or true
+ """
+
+ raise_syntax_error!(
+ message,
+ tag_meta,
+ state
+ )
+ end
+ end
+
+ defp apply_macro_component_directive!(directive, module, tag_meta, state) do
+ raise_syntax_error!(
+ "unknown directive #{inspect(directive)} provided by macro component #{module}",
+ tag_meta,
+ state
+ )
+ end
+
@impl true
def handle_expr(%{tokens: tokens} = state, marker, expr) do
%{state | tokens: [{:expr, marker, expr} | tokens]}
@@ -786,12 +915,13 @@ defmodule Phoenix.LiveView.TagEngine do
attrs = postprocess_attrs(attrs, state)
validate_phx_attrs!(attrs, tag_meta, state)
validate_tag_attrs!(attrs, tag_meta, state)
+ previous_tag = List.first(state.tags)
case pop_special_attrs!(attrs, tag_meta, state) do
{false, tag_meta, attrs} ->
state
|> set_root_on_tag()
- |> handle_tag_and_attrs(name, attrs, suffix, to_location(tag_meta))
+ |> handle_tag_and_attrs(name, attrs, suffix, to_location(tag_meta), previous_tag)
|> continue(tokens)
{true, new_meta, new_attrs} ->
@@ -799,7 +929,7 @@ defmodule Phoenix.LiveView.TagEngine do
|> push_substate_to_stack()
|> update_subengine(:handle_begin, [])
|> set_root_on_not_tag()
- |> handle_tag_and_attrs(name, new_attrs, suffix, to_location(new_meta))
+ |> handle_tag_and_attrs(name, new_attrs, suffix, to_location(new_meta), previous_tag)
|> handle_special_expr(new_meta)
|> continue(tokens)
end
@@ -811,19 +941,19 @@ defmodule Phoenix.LiveView.TagEngine do
attrs = postprocess_attrs(attrs, state)
validate_phx_attrs!(attrs, tag_meta, state)
validate_tag_attrs!(attrs, tag_meta, state)
+ previous_tag = List.first(state.tags)
- case List.keytake(attrs, ":type", 0) do
- {{":type", {:expr, code, _}, _meta}, attrs} ->
- # validate_phx_attrs! already ensured that if :type is present, it is an expression
- handle_macro_component([{:tag, name, attrs, tag_meta} | tokens], code, state)
+ case check_and_validate_macro_component(token, state) do
+ {:macro_component, module, attrs} ->
+ handle_macro_component([{:tag, name, attrs, tag_meta} | tokens], module, state)
- nil ->
+ :tag ->
case pop_special_attrs!(attrs, tag_meta, state) do
{false, tag_meta, attrs} ->
state
|> set_root_on_tag()
|> push_tag(token)
- |> handle_tag_and_attrs(name, attrs, ">", to_location(tag_meta))
+ |> handle_tag_and_attrs(name, attrs, ">", to_location(tag_meta), previous_tag)
|> continue(tokens)
{true, new_meta, new_attrs} ->
@@ -832,7 +962,7 @@ defmodule Phoenix.LiveView.TagEngine do
|> update_subengine(:handle_begin, [])
|> set_root_on_not_tag()
|> push_tag({:tag, name, new_attrs, new_meta})
- |> handle_tag_and_attrs(name, new_attrs, ">", to_location(new_meta))
+ |> handle_tag_and_attrs(name, new_attrs, ">", to_location(new_meta), previous_tag)
|> continue(tokens)
end
end
@@ -851,7 +981,7 @@ defmodule Phoenix.LiveView.TagEngine do
defp handle_macro_component(
[{:tag, _name, _attrs, tag_meta} | _] = tokens,
- module_string,
+ module,
state
) do
# Macro components work by converting the HEEx tokens into an AST
@@ -861,13 +991,6 @@ defmodule Phoenix.LiveView.TagEngine do
#
# The AST is limited in functionality and we handle it separately in
# the handle_ast function.
-
- Macro.Env.required?(state.caller, Phoenix.Component) ||
- raise ArgumentError,
- "macro components are only supported in modules that `use Phoenix.Component`"
-
- module = validate_module!(module_string, tag_meta, state)
-
try do
{ast, rest} =
case Phoenix.Component.MacroComponent.build_ast(tokens, state.caller) do
@@ -955,7 +1078,26 @@ defmodule Phoenix.LiveView.TagEngine do
end)
end
- defp validate_module!(module_string, tag_meta, state) do
+ defp check_and_validate_macro_component({:tag, _name, attrs, tag_meta}, state) do
+ validate_phx_attrs!(attrs, tag_meta, state)
+
+ # validate_phx_attrs! already ensured that if :type is present, it is an expression
+ case List.keytake(attrs, ":type", 0) do
+ {{":type", {:expr, module_string, _}, _meta}, attrs} ->
+ Macro.Env.required?(state.caller, Phoenix.Component) ||
+ raise ArgumentError,
+ "macro components are only supported in modules that `use Phoenix.Component`"
+
+ module = validate_macro_component_module!(module_string, tag_meta, state)
+
+ {:macro_component, module, attrs}
+
+ nil ->
+ :tag
+ end
+ end
+
+ defp validate_macro_component_module!(module_string, tag_meta, state) do
module =
Code.string_to_quoted!(module_string,
file: state.file,
@@ -1053,13 +1195,11 @@ defmodule Phoenix.LiveView.TagEngine do
## handle_tag_and_attrs
- defp handle_tag_and_attrs(state, name, attrs, suffix, meta) do
+ defp handle_tag_and_attrs(state, name, attrs, suffix, meta, previous_tag) do
text =
- if Application.get_env(:phoenix_live_view, :debug_attributes, false) do
- "<#{name} data-phx-loc=\"#{meta[:line]}\""
- else
- "<#{name}"
- end
+ "<#{name}"
+ |> maybe_add_phx_loc(meta)
+ |> maybe_add_root_tag_attributes(state, previous_tag)
state
|> update_subengine(:handle_text, [meta, text])
@@ -1067,6 +1207,38 @@ defmodule Phoenix.LiveView.TagEngine do
|> update_subengine(:handle_text, [meta, suffix])
end
+ defp maybe_add_phx_loc(text, meta) do
+ if Application.get_env(:phoenix_live_view, :debug_attributes, false) do
+ "#{text} data-phx-loc=\"#{meta[:line]}\""
+ else
+ "#{text}"
+ end
+ end
+
+ defp maybe_add_root_tag_attributes(text, state, previous_tag) do
+ case get_root_tag_attribute() do
+ root_tag_attribute when is_binary(root_tag_attribute) ->
+ # By checking if the previous tag that was pushed was a normal html tag,
+ # we effectively check if the tag we are dealing with is a "local" root
+ # (root tag of the whole template, a component's inner block, or a slot)
+ # or not.
+ if not match?({:tag, _, _, _}, previous_tag) do
+ attrs =
+ [{root_tag_attribute, true} | state.root_tag_attributes]
+ |> Phoenix.HTML.attributes_escape()
+ |> Phoenix.HTML.safe_to_string()
+
+ # Phoenix.HTML.attributes_escape/1 adds a leading space automatically
+ "#{text}#{attrs}"
+ else
+ text
+ end
+
+ _ ->
+ text
+ end
+ end
+
defp handle_tag_attrs(state, meta, attrs) do
Enum.reduce(attrs, state, fn
{:root, {:expr, _, _} = expr, _attr_meta}, state ->
@@ -1487,6 +1659,10 @@ defmodule Phoenix.LiveView.TagEngine do
## Helpers
+ defp get_root_tag_attribute() do
+ Application.get_env(:phoenix_live_view, :root_tag_attribute, @default_root_tag_attribute)
+ end
+
defp to_location(%{line: line, column: column}), do: [line: line, column: column]
defp actual_component_module(env, fun) do
diff --git a/lib/phoenix_live_view/tokenizer.ex b/lib/phoenix_live_view/tokenizer.ex
index 93c9f0f842..5c6e2f311d 100644
--- a/lib/phoenix_live_view/tokenizer.ex
+++ b/lib/phoenix_live_view/tokenizer.ex
@@ -735,7 +735,8 @@ defmodule Phoenix.LiveView.Tokenizer do
[{type, name, attrs, meta} | acc]
end
- defp strip_text_token_fully(tokens) do
+ @doc false
+ def strip_text_token_fully(tokens) do
with [{:text, text, _} | rest] <- tokens,
"" <- String.trim_leading(text) do
strip_text_token_fully(rest)
diff --git a/test/phoenix_component/macro_component_integration_test.exs b/test/phoenix_component/macro_component_integration_test.exs
index f18ce446bd..d73c4c4e6a 100644
--- a/test/phoenix_component/macro_component_integration_test.exs
+++ b/test/phoenix_component/macro_component_integration_test.exs
@@ -1,5 +1,7 @@
defmodule Phoenix.Component.MacroComponentIntegrationTest do
- use ExUnit.Case, async: true
+ # async: false due to manipulating the Application env
+ # for :root_tag_attribute
+ use ExUnit.Case, async: false
use Phoenix.Component
@@ -19,6 +21,52 @@ defmodule Phoenix.Component.MacroComponentIntegrationTest do
end
end
+ defmodule DirectiveMacroComponent do
+ @behaviour Phoenix.Component.MacroComponent
+
+ @impl true
+ def directives(_ast, _meta) do
+ {:ok,
+ [
+ root_tag_attribute: {"phx-sample-one", "test"},
+ root_tag_attribute: {"phx-sample-two", "test"}
+ ]}
+ end
+
+ @impl true
+ def transform(_ast, _meta) do
+ {:ok, "", %{}}
+ end
+ end
+
+ defmodule BadRootTagAttrDirectiveMacroComponent do
+ @behaviour Phoenix.Component.MacroComponent
+
+ @impl true
+ def directives(_ast, _meta) do
+ {:ok, [root_tag_attribute: false]}
+ end
+
+ @impl true
+ def transform(_ast, _meta) do
+ {:ok, "", %{}}
+ end
+ end
+
+ defmodule UnknownDirectiveMacroComponent do
+ @behaviour Phoenix.Component.MacroComponent
+
+ @impl true
+ def directives(_ast, _meta) do
+ {:ok, [unknown: true]}
+ end
+
+ @impl true
+ def transform(_ast, _meta) do
+ {:ok, "", %{}}
+ end
+ end
+
test "receives ast" do
defmodule TestComponentAst do
use Phoenix.Component
@@ -239,6 +287,254 @@ defmodule Phoenix.Component.MacroComponentIntegrationTest do
end
end
+ test "raises if :root_tag_attribute is not configured and the :root_tag_attribute directive is provided" do
+ message = ~r"""
+ a global :root_tag_attribute must be configured for macro components to use the :root_tag_attribute directive
+
+ Macro Component: #{__MODULE__}\.DirectiveMacroComponent
+
+ Expected global :root_tag_attribute to be a string, got: nil
+
+ The global :root_tag_attribute defaults to "phx-r". If you are getting this warning, the global
+ :root_tag_attribute has been explicitly disabled in your application's config. You will be unable
+ to use this macro component until the global `:root_tag_attribute` is re-enabled.
+
+ If you wish to use a global :root_tag_attribute other than "phx-r", you can configure a
+ custom attribute like so:
+
+ config :phoenix_live_view, root_tag_attribute: "your-attribute-here"
+ """
+
+ assert_raise Phoenix.LiveView.Tokenizer.ParseError,
+ message,
+ fn ->
+ defmodule TestRootTagAttrNotConfig do
+ use Phoenix.Component
+
+ def render(assigns) do
+ ~H"""
+
+ """
+ end
+ end
+ end
+ end
+
+ test "raises if an unknown directive is provided" do
+ message =
+ ~r/unknown directive {:unknown, true} provided by macro component #{__MODULE__}\.UnknownDirectiveMacroComponent/
+
+ assert_raise Phoenix.LiveView.Tokenizer.ParseError,
+ message,
+ fn ->
+ defmodule TestUnknownDirective do
+ use Phoenix.Component
+
+ def render(assigns) do
+ ~H"""
+
+ """
+ end
+ end
+ end
+ end
+
+ describe "macro components with directives" do
+ setup do
+ # Need to set a :root_tag_attribute as the only directive supported
+ # by macro components currently is [root_tag_attribute: {name, value}] which
+ # requires a :root_tag_attribute to be configured
+ Application.put_env(:phoenix_live_view, :root_tag_attribute, "phx-r")
+ on_exit(fn -> Application.put_env(:phoenix_live_view, :root_tag_attribute, nil) end)
+ end
+
+ test "raises if :root_tag_attribute directive is provided with an invalid value" do
+ message = ~r"""
+ expected {name, value} for :root_tag_attribute directive from macro component #{__MODULE__}\.BadRootTagAttrDirectiveMacroComponent, got: false
+
+ name must be a compile-time string, and value must be a compile-time string or true
+ """
+
+ assert_raise Phoenix.LiveView.Tokenizer.ParseError,
+ message,
+ fn ->
+ defmodule TestBadRootTagAttrDirective do
+ use Phoenix.Component
+
+ def render(assigns) do
+ ~H"""
+
+ """
+ end
+ end
+ end
+ end
+
+ test "raises if macro components with directives are not defined at the beginning of the template" do
+ message =
+ ~r/macro component #{__MODULE__}\.DirectiveMacroComponent specified directives and therefore must appear at the very beginning of the template/
+
+ defmodule TestComponentDirectiveAtBeginning1 do
+ use Phoenix.Component
+
+ def render(assigns) do
+ ~H"""
+
+ """
+ end
+ end
+
+ defmodule TestComponentDirectiveAtBeginning2 do
+ use Phoenix.Component
+
+ def render(assigns) do
+ ~H"""
+
+ """
+ end
+ end
+
+ assert_raise Phoenix.LiveView.Tokenizer.ParseError,
+ message,
+ fn ->
+ defmodule TestComponentDirectiveAtBeginning3 do
+ use Phoenix.Component
+
+ def render(assigns) do
+ ~H"""
+
+
+ """
+ end
+ end
+ end
+
+ assert_raise Phoenix.LiveView.Tokenizer.ParseError,
+ message,
+ fn ->
+ defmodule TestComponentDirectiveAtBeginning4 do
+ use Phoenix.Component
+
+ def render(assigns) do
+ ~H"""
+
+
+ """
+ end
+ end
+ end
+
+ assert_raise Phoenix.LiveView.Tokenizer.ParseError,
+ message,
+ fn ->
+ defmodule TestComponentDirectiveAtBeginning5 do
+ use Phoenix.Component
+
+ def render(assigns) do
+ ~H"""
+ <.link>Link
+
+ """
+ end
+ end
+ end
+
+ assert_raise Phoenix.LiveView.Tokenizer.ParseError,
+ message,
+ fn ->
+ defmodule TestComponentDirectiveAtBeginning6 do
+ use Phoenix.Component
+
+ def render(assigns) do
+ ~H"""
+ Link
+
+ """
+ end
+ end
+ end
+
+ assert_raise Phoenix.LiveView.Tokenizer.ParseError,
+ message,
+ fn ->
+ defmodule TestComponentDirectiveAtBeginning7 do
+ use Phoenix.Component
+
+ def render(assigns) do
+ ~H"""
+ {if true, do: "Test"}
+
+ """
+ end
+ end
+ end
+
+ assert_raise Phoenix.LiveView.Tokenizer.ParseError,
+ message,
+ fn ->
+ defmodule TestComponentDirectiveAtBeginning8 do
+ use Phoenix.Component
+
+ def render(assigns) do
+ ~H"""
+ <%= if true do %>
+
+ <% end %>
+ """
+ end
+ end
+ end
+
+ assert_raise Phoenix.LiveView.Tokenizer.ParseError,
+ message,
+ fn ->
+ defmodule TestComponentDirectiveAtBeginning9 do
+ use Phoenix.Component
+
+ def render(assigns) do
+ ~H"""
+
+ """
+ end
+ end
+ end
+
+ assert_raise Phoenix.LiveView.Tokenizer.ParseError,
+ message,
+ fn ->
+ defmodule TestComponentDirectiveAtBeginning10 do
+ use Phoenix.Component
+
+ def render(assigns) do
+ ~H"""
+ <.link>
+
+
+ """
+ end
+ end
+ end
+
+ assert_raise Phoenix.LiveView.Tokenizer.ParseError,
+ message,
+ fn ->
+ defmodule TestComponentDirectiveAtBeginning11 do
+ use Phoenix.Component
+
+ def render(assigns) do
+ ~H"""
+
+
+
+ """
+ end
+ end
+ end
+ end
+ end
+
test "handles quotes" do
Process.put(
:new_ast,
diff --git a/test/phoenix_live_view/html_engine_test.exs b/test/phoenix_live_view/html_engine_test.exs
index ce9bbbf50a..1255067de3 100644
--- a/test/phoenix_live_view/html_engine_test.exs
+++ b/test/phoenix_live_view/html_engine_test.exs
@@ -7,6 +7,14 @@ defmodule Phoenix.LiveView.HTMLEngineTest do
alias Phoenix.LiveView.Tokenizer.ParseError
+ defp normalize_whitespace(string) do
+ # Eliminate all newlines and space between tags to make
+ # assertions more resilient against irrelevant whitespace differences
+ string
+ |> String.replace("\n", "")
+ |> String.replace(~r/> +, "><")
+ end
+
defp eval(string, assigns \\ %{}, opts \\ []) do
{env, opts} = Keyword.pop(opts, :env, __ENV__)
@@ -459,6 +467,315 @@ defmodule Phoenix.LiveView.HTMLEngineTest do
end
end
+ describe "root tag attributes" do
+ alias Phoenix.LiveViewTest.Support.RootTagAttr
+
+ test "single self-closing tag" do
+ assigns = %{}
+
+ compiled = compile("")
+
+ expected = ""
+
+ assert normalize_whitespace(compiled) == normalize_whitespace(expected)
+ end
+
+ test "single tag with body" do
+ assigns = %{}
+
+ compiled = compile("")
+
+ expected = "Test
"
+
+ assert normalize_whitespace(compiled) == normalize_whitespace(expected)
+ end
+
+ test "multiple self-closing tags" do
+ assigns = %{}
+
+ compiled = compile("")
+
+ expected = """
+
+
+
+ """
+
+ assert normalize_whitespace(compiled) == normalize_whitespace(expected)
+ end
+
+ test "multiple tags with bodies" do
+ assigns = %{}
+
+ compiled = compile("")
+
+ expected = """
+ Test1
+ Test2
+ Test3
+ """
+
+ assert normalize_whitespace(compiled) == normalize_whitespace(expected)
+ end
+
+ test "tags root tags of nested tags" do
+ assigns = %{}
+
+ compiled = compile("")
+
+ expected = """
+
+
+ """
+
+ assert normalize_whitespace(compiled) == normalize_whitespace(expected)
+ end
+
+ test "tags root tags of component inner_blocks" do
+ assigns = %{}
+
+ compiled = compile("")
+
+ expected =
+ """
+
+ """
+
+ assert normalize_whitespace(compiled) == normalize_whitespace(expected)
+ end
+
+ test "tags root tags of component named slots" do
+ assigns = %{}
+
+ compiled = compile("")
+
+ expected =
+ """
+
+ """
+
+ assert normalize_whitespace(compiled) == normalize_whitespace(expected)
+ end
+
+ test "tags root tags correctly for complex nestings of tags, components, and slots" do
+ assigns = %{}
+
+ compiled = compile("")
+
+ expected =
+ """
+
+ """
+
+ assert normalize_whitespace(compiled) == normalize_whitespace(expected)
+ end
+
+ test "within nestings" do
+ assigns = %{}
+
+ compiled = compile("")
+
+ expected = """
+
+ """
+
+ assert normalize_whitespace(compiled) == normalize_whitespace(expected)
+
+ compiled = compile("")
+
+ expected = """
+
+ """
+
+ assert normalize_whitespace(compiled) == normalize_whitespace(expected)
+ end
+
+ test "extra attributes with values provided by macro component directives" do
+ assigns = %{}
+
+ compiled = compile("")
+
+ expected =
+ """
+
+ """
+
+ assert normalize_whitespace(compiled) == normalize_whitespace(expected)
+ end
+
+ test "extra attributes without values provided by macro component directives" do
+ assigns = %{}
+
+ compiled = compile("")
+
+ expected =
+ """
+
+ """
+
+ assert normalize_whitespace(compiled) == normalize_whitespace(expected)
+ end
+
+ test "extra attributes with values provided by macro component directives within nestings" do
+ assigns = %{}
+
+ compiled =
+ compile("")
+
+ expected = """
+
+ """
+
+ assert normalize_whitespace(compiled) == normalize_whitespace(expected)
+
+ compiled =
+ compile("")
+
+ expected = """
+
+ """
+
+ assert normalize_whitespace(compiled) == normalize_whitespace(expected)
+ end
+ end
+
describe "handle function components" do
test "remote call (self close)" do
assigns = %{}
diff --git a/test/support/live_views/root_tag_attr.exs b/test/support/live_views/root_tag_attr.exs
new file mode 100644
index 0000000000..3ebaf14d76
--- /dev/null
+++ b/test/support/live_views/root_tag_attr.exs
@@ -0,0 +1,285 @@
+# Note this file is intentionally a .exs file because it is loaded
+# in the test helper with :root_tag_attribute turned on.
+defmodule Phoenix.LiveViewTest.Support.RootTagAttr do
+ use Phoenix.Component
+
+ defmodule RootTagsWithValuesMacroComponent do
+ @behaviour Phoenix.Component.MacroComponent
+
+ @impl true
+ def directives(_ast, _meta) do
+ {:ok,
+ [
+ root_tag_attribute: {"phx-sample-one", "test"},
+ root_tag_attribute: {"phx-sample-two", "test"}
+ ]}
+ end
+
+ @impl true
+ def transform(_ast, _meta) do
+ {:ok, "", %{}}
+ end
+ end
+
+ defmodule RootTagsWithoutValuesMacroComponent do
+ @behaviour Phoenix.Component.MacroComponent
+
+ @impl true
+ def directives(_ast, _meta) do
+ {:ok,
+ [
+ root_tag_attribute: {"phx-sample-one", true},
+ root_tag_attribute: {"phx-sample-two", true}
+ ]}
+ end
+
+ @impl true
+ def transform(_ast, _meta) do
+ {:ok, "", %{}}
+ end
+ end
+
+ def macro_component_attrs_with_values_within_nestings(assigns) do
+ ~H"""
+
+ <%= if true do %>
+
+
+ <%= if @bool do %>
+ <.inner_block_and_slot>
+
+ True
+
+
+ <% else %>
+ <.inner_block_and_slot>
+
+ False
+
+
+ <% end %>
+
+
+ <% end %>
+ """
+ end
+
+ def within_nestings(assigns) do
+ ~H"""
+ <%= if true do %>
+
+
+ <%= if @bool do %>
+ <.inner_block_and_slot>
+
+ True
+
+
+ <% else %>
+ <.inner_block_and_slot>
+
+ False
+
+
+ <% end %>
+
+
+ <% end %>
+ """
+ end
+
+ def macro_component_attrs_with_values(assigns) do
+ ~H"""
+
+
+
+ <.inner_block_and_slot>
+
Inner Block
+ <:test>
+
+ Named Slot
+
+
+
+
+
+ """
+ end
+
+ def macro_component_attrs_without_values(assigns) do
+ ~H"""
+
+
+
+ <.inner_block_and_slot>
+
Inner Block
+ <:test>
+
+ Named Slot
+
+
+
+
+
+ """
+ end
+
+ slot :inner_block, required: true
+ slot :test
+
+ def single_self_close(assigns) do
+ ~H"""
+
+ """
+ end
+
+ def single_with_body(assigns) do
+ ~H"""
+ Test
+ """
+ end
+
+ def multiple_self_close(assigns) do
+ ~H"""
+
+
+
+ """
+ end
+
+ def multiple_with_bodies(assigns) do
+ ~H"""
+ Test1
+ Test2
+ Test3
+ """
+ end
+
+ def nested_tags(assigns) do
+ ~H"""
+
+
+ """
+ end
+
+ def component_inner_blocks(assigns) do
+ ~H"""
+
+
+ <.inner_block_and_slot>
+
+
+ <.inner_block_and_slot>
+
+
+
+
+ """
+ end
+
+ def component_named_slots(assigns) do
+ ~H"""
+
+
+ <.inner_block_and_slot>
+ <:test>
+
+
+
+ <.inner_block_and_slot>
+ <:test>
+
+
+
+
+
+ """
+ end
+
+ def nested_tags_components_slots(assigns) do
+ ~H"""
+
+
+ <.inner_block_and_slot>
+
+ <.inner_block_and_slot>
+
+ <.simple />
+
+ <:test>
+
+ <.simple />
+
+
+
+
+ <:test>
+
+ <.inner_block_and_slot>
+
+ <.simple />
+
+ <:test>
+
+ <.simple />
+
+
+
+
+
+
+
+
+ """
+ end
+
+ slot :inner_block, required: true
+ slot :test
+
+ defp inner_block_and_slot(assigns) do
+ ~H"""
+
+ {render_slot(@inner_block)}
+
+
+ """
+ end
+
+ defp simple(assigns) do
+ ~H"""
+ Simple
+ """
+ end
+end
diff --git a/test/test_helper.exs b/test/test_helper.exs
index 5cb28257b1..294dfa4b36 100644
--- a/test/test_helper.exs
+++ b/test/test_helper.exs
@@ -4,5 +4,9 @@ Code.require_file("test/support/live_views/debug_anno.exs")
Application.put_env(:phoenix_live_view, :debug_attributes, false)
Application.put_env(:phoenix_live_view, :debug_heex_annotations, false)
+Application.put_env(:phoenix_live_view, :root_tag_attribute, "phx-r")
+Code.require_file("test/support/live_views/root_tag_attr.exs")
+Application.put_env(:phoenix_live_view, :root_tag_attribute, nil)
+
{:ok, _} = Phoenix.LiveViewTest.Support.Endpoint.start_link()
ExUnit.start()