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 = + """ +
+
+
+
+
+ Inner Block 1 +
+
+
+
+
+
+ Inner Block 2 +
+
+
+
+
+ """ + + 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 = + """ +
+
+
+
+
+
+

Simple

+
+ +
+
+ +
+
+
+ """ + + assert normalize_whitespace(compiled) == normalize_whitespace(expected) + end + + test "within nestings" do + assigns = %{} + + compiled = compile("") + + expected = """ +
+
+
+

+ True +

+
+
+
+ """ + + assert normalize_whitespace(compiled) == normalize_whitespace(expected) + + compiled = compile("") + + expected = """ +
+
+
+

+ False +

+
+
+
+ """ + + assert normalize_whitespace(compiled) == normalize_whitespace(expected) + end + + test "extra attributes with values provided by macro component directives" do + assigns = %{} + + compiled = compile("") + + expected = + """ +
+
+
+
Inner Block
+ +
+
+
+ """ + + assert normalize_whitespace(compiled) == normalize_whitespace(expected) + end + + test "extra attributes without values provided by macro component directives" do + assigns = %{} + + compiled = compile("") + + expected = + """ +
+
+
+
Inner Block
+ +
+
+
+ """ + + 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 = """ +
+
+
+

+ True +

+
+
+
+ """ + + assert normalize_whitespace(compiled) == normalize_whitespace(expected) + + compiled = + compile("") + + expected = """ +
+
+
+

+ False +

+
+
+
+ """ + + 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 1 +
+
+ + <.inner_block_and_slot> +
+
+ Inner Block 2 +
+
+ +
+
+ """ + end + + def component_named_slots(assigns) do + ~H""" +
+
+ <.inner_block_and_slot> + <:test> +
+
+ Inner Block 1 +
+
+ + + <.inner_block_and_slot> + <:test> +
+
+ Inner Block 2 +
+
+ + +
+
+ """ + 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()