Skip to content

Commit d838e78

Browse files
Add MacroComponent directives and Root Tag Attribute
Supersedes #4116. Co-Authored-By: David Green <134172184+green-david@users.noreply.github.com>
1 parent 2a13c5b commit d838e78

5 files changed

Lines changed: 202 additions & 48 deletions

File tree

lib/phoenix_component/macro_component.ex

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,29 @@ defmodule Phoenix.Component.MacroComponent do
120120
# LiveView's end to end tests: a macro component that performs
121121
# [syntax highlighting at compile time](https://github.com/phoenixframework/phoenix_live_view/blob/38851d943f3280c5982d75679291dccb8c442534/test/e2e/support/colocated_live.ex#L4-L35)
122122
# using the [Makeup](https://hexdocs.pm/makeup/Makeup.html) library.
123+
#
124+
# ## Directives
125+
#
126+
# Macro components may return directives from `transform/2` which can be used to influence
127+
# other elements in the template outside of the macro component at compile-time. For example:
128+
#
129+
# ```elixir
130+
# defmodule MyAppWeb.TagRootSampleComponent do
131+
# @behaviour Phoenix.Component.MacroComponent
132+
#
133+
# @impl true
134+
# def transform(_ast, _meta) do
135+
# {:ok, "", %{}, [root_tag_annotation: "test1", root_tag_annotation: "test2"]}
136+
# end
137+
# end
138+
# ```
139+
#
140+
# The following directives are currently supported:
141+
#
142+
# * `:root_tag_annotation` - A value to apply as an annotation to all root tags during template compilation.
143+
# Requires that a `:root_tag_annotation` is configured for the application. The value must be a string.
144+
# May be provided multiple times to apply multiple annotations.
145+
#
123146

124147
@type tag :: binary()
125148
@type attribute :: {binary(), Macro.t()}
@@ -128,9 +151,13 @@ defmodule Phoenix.Component.MacroComponent do
128151
@type tag_meta :: %{closing: :self | :void}
129152
@type heex_ast :: {tag(), attributes(), children(), tag_meta()} | binary()
130153
@type transform_meta :: %{env: Macro.Env.t()}
154+
@type directive :: {:root_tag_annotation, String.t()}
155+
@type directives :: [directive]
131156

132157
@callback transform(heex_ast :: heex_ast(), meta :: transform_meta()) ::
133-
{:ok, heex_ast()} | {:ok, heex_ast(), data :: term()}
158+
{:ok, heex_ast()}
159+
| {:ok, heex_ast(), data :: term()}
160+
| {:ok, heex_ast(), data :: term(), directives :: directives()}
134161

135162
@doc """
136163
Returns the stored data from macro components that returned `{:ok, ast, data}`.

lib/phoenix_live_view/html_formatter.ex

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -312,8 +312,8 @@ defmodule Phoenix.LiveView.HTMLFormatter do
312312
process_buffer: &process_buffer/1
313313
)
314314
|> case do
315-
{:ok, nodes} ->
316-
nodes
315+
{:ok, result} ->
316+
result.nodes
317317
|> transform_tree(source, newlines)
318318
|> HTMLAlgebra.build(opts)
319319
|> Inspect.Algebra.format(line_length)

lib/phoenix_live_view/tag_engine/compiler.ex

Lines changed: 46 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
defmodule Phoenix.LiveView.TagEngine.Compiler do
22
@moduledoc false
33

4+
alias Phoenix.LiveView.TagEngine.ParseResult
45
alias Phoenix.LiveView.TagEngine.Tokenizer.ParseError
56

67
@doc """
@@ -15,7 +16,7 @@ defmodule Phoenix.LiveView.TagEngine.Compiler do
1516
text and expression parts and properly invoking the engine
1617
with the correct code for features like components and slots.
1718
"""
18-
def compile(nodes, opts) do
19+
def compile(%ParseResult{nodes: nodes, directives: directives}, opts) do
1920
{engine, opts} = Keyword.pop(opts, :engine, Phoenix.LiveView.Engine)
2021
tag_handler = Keyword.fetch!(opts, :tag_handler)
2122

@@ -26,8 +27,11 @@ defmodule Phoenix.LiveView.TagEngine.Compiler do
2627
caller: Keyword.fetch!(opts, :caller),
2728
source: Keyword.fetch!(opts, :source),
2829
tag_handler: tag_handler,
29-
# slots is the only key that is updated when traversing nodes
30-
slots: []
30+
root_tag_attribute: Application.get_env(:phoenix_live_view, :root_tag_attribute, false),
31+
root_tag_attributes: Keyword.get_values(directives, :root_tag_attribute),
32+
# The following keys are updated when traversing nodes
33+
slots: [],
34+
local_root?: true
3135
}
3236

3337
# Live components require a single, static root tag.
@@ -328,7 +332,7 @@ defmodule Phoenix.LiveView.TagEngine.Compiler do
328332

329333
with_special_attrs(attrs, meta, substate, state, fn attrs, meta, substate, state ->
330334
substate = handle_tag_and_attrs(name, attrs, ">", to_location(meta), substate, state)
331-
{_child_state, substate} = handle_node(children, substate, state)
335+
{_child_state, substate} = handle_node(children, substate, %{state | local_root?: false})
332336
substate = state.engine.handle_text(substate, [to_location(close_meta)], "</#{name}>")
333337
{state, substate}
334338
end)
@@ -353,7 +357,7 @@ defmodule Phoenix.LiveView.TagEngine.Compiler do
353357
meta,
354358
close_meta,
355359
substate,
356-
state
360+
%{state | local_root?: true}
357361
)
358362

359363
ast =
@@ -383,7 +387,10 @@ defmodule Phoenix.LiveView.TagEngine.Compiler do
383387

384388
with_special_attrs(attrs, meta, substate, state, fn attrs, meta, substate, state ->
385389
{assigns, attr_info, slot_info} =
386-
build_component_assigns(ref, attrs, children, meta, close_meta, substate, state)
390+
build_component_assigns(ref, attrs, children, meta, close_meta, substate, %{
391+
state
392+
| local_root?: true
393+
})
387394

388395
store_component_call({mod, fun}, attr_info, slot_info, line, state)
389396
call_meta = [line: line, column: column]
@@ -420,7 +427,10 @@ defmodule Phoenix.LiveView.TagEngine.Compiler do
420427
with_special_attrs(attrs, meta, substate, state, fn attrs, meta, substate, state ->
421428
# Process children in a new nesting
422429
{assigns, attr_info, slot_info} =
423-
build_component_assigns(ref, attrs, children, meta, close_meta, substate, state)
430+
build_component_assigns(ref, attrs, children, meta, close_meta, substate, %{
431+
state
432+
| local_root?: true
433+
})
424434

425435
store_component_call({mod, fun}, attr_info, slot_info, line, state)
426436
call_meta = [line: line, column: column + mod_size]
@@ -479,17 +489,41 @@ defmodule Phoenix.LiveView.TagEngine.Compiler do
479489

480490
defp handle_tag_and_attrs(name, attrs, suffix, meta, substate, state) do
481491
text =
482-
if debug_attributes?(state.caller) do
483-
"<#{name} data-phx-loc=\"#{meta[:line]}\""
484-
else
485-
"<#{name}"
486-
end
492+
"<#{name}"
493+
|> maybe_add_phx_loc(state, meta)
494+
|> maybe_add_root_tag_attributes(state, meta)
487495

488496
substate = state.engine.handle_text(substate, meta, text)
489497
substate = handle_tag_attrs(meta, attrs, substate, state)
490498
state.engine.handle_text(substate, meta, suffix)
491499
end
492500

501+
defp maybe_add_phx_loc(text, %{caller: caller}, meta) do
502+
if debug_attributes?(caller) do
503+
"#{text} data-phx-loc=\"#{meta[:line]}\""
504+
else
505+
text
506+
end
507+
end
508+
509+
defp maybe_add_root_tag_attributes(text, %{local_root?: true} = state, _meta) do
510+
case state do
511+
%{root_tag_attribute: root_tag_attribute} when is_binary(root_tag_attribute) ->
512+
attrs =
513+
[{root_tag_attribute, true} | state.root_tag_attributes]
514+
|> Phoenix.HTML.attributes_escape()
515+
|> Phoenix.HTML.safe_to_string()
516+
517+
# Phoenix.HTML.attributes_escape/1 adds a leading space automatically
518+
"#{text}#{attrs}"
519+
520+
%{root_tag_attribute: _} ->
521+
text
522+
end
523+
end
524+
525+
defp maybe_add_root_tag_attributes(text, _state, _meta), do: text
526+
493527
defp handle_tag_attrs(meta, attrs, substate, state) do
494528
Enum.reduce(attrs, substate, fn
495529
{:root, {:expr, _, _} = expr, _attr_meta}, substate ->

lib/phoenix_live_view/tag_engine/parser.ex

Lines changed: 102 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ defmodule Phoenix.LiveView.TagEngine.Parser do
22
@moduledoc false
33

44
alias Phoenix.LiveView.TagEngine.Tokenizer
5+
alias Phoenix.LiveView.TagEngine.ParseResult
56
alias Phoenix.Component.MacroComponent
67

78
defguardp is_tag_open(tag_type) when tag_type not in [:close, :eex]
@@ -30,7 +31,8 @@ defmodule Phoenix.LiveView.TagEngine.Parser do
3031
caller: caller,
3132
skip_macro_components: skip_macro_components,
3233
prune_text_after_slots: prune_text_after_slots,
33-
process_buffer: process_buffer
34+
process_buffer: process_buffer,
35+
directives: []
3436
})
3537
catch
3638
{:syntax_error, line, column, message} ->
@@ -233,8 +235,8 @@ defmodule Phoenix.LiveView.TagEngine.Parser do
233235
# ], %{line: 0, column: 0, opt: '='}}
234236
# ]
235237
# ```
236-
defp to_tree([], buffer, [], _state) do
237-
{:ok, Enum.reverse(buffer)}
238+
defp to_tree([], buffer, [], state) do
239+
{:ok, %ParseResult{nodes: Enum.reverse(buffer), directives: state.directives}}
238240
end
239241

240242
defp to_tree(
@@ -269,7 +271,7 @@ defmodule Phoenix.LiveView.TagEngine.Parser do
269271
when parent_type in [:local_component, :remote_component] do
270272
meta = meta |> Map.put(:special, extract_special_attrs(attrs)) |> maybe_macro_component(attrs)
271273

272-
{tokens, buffer} =
274+
{tokens, buffer, state} =
273275
maybe_process_macro_component(
274276
{:self_close, :slot, name, attrs, meta},
275277
tokens,
@@ -331,7 +333,7 @@ defmodule Phoenix.LiveView.TagEngine.Parser do
331333
when is_tag_open(type) do
332334
meta = meta |> Map.put(:special, extract_special_attrs(attrs)) |> maybe_macro_component(attrs)
333335

334-
{tokens, buffer} =
336+
{tokens, buffer, state} =
335337
maybe_process_macro_component({:self_close, type, name, attrs, meta}, tokens, buffer, state)
336338

337339
to_tree(tokens, buffer, stack, state)
@@ -353,7 +355,7 @@ defmodule Phoenix.LiveView.TagEngine.Parser do
353355
) do
354356
block = Enum.reverse(reversed_buffer)
355357

356-
{tokens, buffer} =
358+
{tokens, buffer, state} =
357359
maybe_process_macro_component(
358360
{:block, type, name, attrs, block, open_meta, close_meta},
359361
tokens,
@@ -522,9 +524,19 @@ defmodule Phoenix.LiveView.TagEngine.Parser do
522524

523525
defp maybe_process_macro_component(tree_node, tokens, buffer, state)
524526
when is_macro_component(tree_node) and not skip_macro_components(state) do
525-
# Remove :type from attrs for the macro component AST
527+
caller = state.caller
528+
529+
if is_nil(caller) do
530+
raise ArgumentError, "macro components require a caller environment"
531+
end
532+
533+
Macro.Env.required?(caller, Phoenix.Component) ||
534+
raise ArgumentError,
535+
"macro components are only supported in modules that `use Phoenix.Component`"
536+
526537
tree_node =
527538
case tree_node do
539+
# Remove :type from attrs for the macro component AST
528540
{:self_close, :tag, name, attrs, meta} ->
529541
{:self_close, :tag, name, List.keydelete(attrs, ":type", 0), meta}
530542

@@ -545,54 +557,57 @@ defmodule Phoenix.LiveView.TagEngine.Parser do
545557
)
546558
end
547559

548-
case process_macro_component(tree_node, state) do
549-
{:text, "", _} ->
550-
{strip_text(tokens), buffer}
560+
meta = get_meta(tree_node)
561+
module_string = meta.macro_component
562+
module = validate_module!(module_string, meta, state)
563+
564+
case process_macro_component(tree_node, module, state) do
565+
{_new_node, directives} when directives != [] and buffer != [] ->
566+
throw_syntax_error!(
567+
"macro component #{inspect(module)} specified directives and therefore must appear at the very beginning of the template",
568+
get_meta(tree_node)
569+
)
570+
571+
{{:text, "", _}, directives} ->
572+
{strip_text(tokens), buffer, %{state | directives: directives}}
551573

552-
new_node ->
553-
{strip_text(tokens), [new_node | buffer]}
574+
{new_node, directives} ->
575+
{strip_text(tokens), [new_node | buffer], %{state | directives: directives}}
554576
end
555577
end
556578

557-
defp maybe_process_macro_component(tree_node, tokens, buffer, _state) do
558-
{tokens, [tree_node | buffer]}
579+
defp maybe_process_macro_component(tree_node, tokens, buffer, state) do
580+
{tokens, [tree_node | buffer], state}
559581
end
560582

561583
# Process a macro component: call transform and convert result back to tree nodes
562-
defp process_macro_component(tree_node, state) do
584+
defp process_macro_component(tree_node, module, state) do
563585
# Macro components work by converting the tree nodes into a macro AST
564586
# (see Phoenix.Component.MacroComponent) and then calling the transform
565587
# function on the macro component module, which can return a transformed
566588
# AST.
567589
#
568590
# The AST is limited in functionality and we convert it back to the regular
569591
# node format afterwards.
570-
caller = state.caller
571-
572-
if is_nil(caller) do
573-
raise ArgumentError, "macro components require a caller environment"
574-
end
575-
576-
Macro.Env.required?(caller, Phoenix.Component) ||
577-
raise ArgumentError,
578-
"macro components are only supported in modules that `use Phoenix.Component`"
579-
580592
meta = get_meta(tree_node)
581-
module_string = meta.macro_component
582-
module = validate_module!(module_string, meta, state)
583593

584-
# Build the macro component AST
585-
case MacroComponent.build_ast(tree_node, caller) do
594+
case MacroComponent.build_ast(tree_node, state.caller) do
586595
{:ok, macro_ast} ->
587596
try do
588597
# Call the transform function
589-
case module.transform(macro_ast, %{env: caller}) do
598+
case module.transform(macro_ast, %{env: state.caller}) do
590599
{:ok, new_ast} ->
591-
MacroComponent.ast_to_tree(new_ast, meta)
600+
{MacroComponent.ast_to_tree(new_ast, meta), []}
592601

593602
{:ok, new_ast, data} ->
594-
Module.put_attribute(caller.module, :__macro_components__, {module, data})
595-
MacroComponent.ast_to_tree(new_ast, meta)
603+
Module.put_attribute(state.caller.module, :__macro_components__, {module, data})
604+
{MacroComponent.ast_to_tree(new_ast, meta), []}
605+
606+
{:ok, new_ast, data, directives} ->
607+
Module.put_attribute(state.caller.module, :__macro_components__, {module, data})
608+
609+
{MacroComponent.ast_to_tree(new_ast, meta),
610+
validate_directives!(module, directives, meta)}
596611

597612
other ->
598613
throw_syntax_error!(
@@ -641,6 +656,60 @@ defmodule Phoenix.LiveView.TagEngine.Parser do
641656
module
642657
end
643658

659+
defp validate_directives!(module, directives, meta) do
660+
Enum.each(directives, fn {key, value} ->
661+
validate_directive!(module, key, value, meta)
662+
end)
663+
664+
directives
665+
end
666+
667+
defp validate_directive!(_module, :root_tag_attribute, nil, _), do: :ok
668+
669+
defp validate_directive!(module, :root_tag_attribute, {name, value}, meta)
670+
when is_binary(name) and (is_binary(value) or value == true) do
671+
configured_attribute = Application.get_env(:phoenix_live_view, :root_tag_attribute)
672+
673+
if !configured_attribute do
674+
message = """
675+
a global :root_tag_attribute must be configured for macro components to use the :root_tag_attribute directive
676+
677+
Macro Component: #{inspect(module)}
678+
679+
Expected global :root_tag_attribute to be a string, got: #{inspect(configured_attribute)}
680+
681+
The global :root_tag_attribute is typically used for `Phoenix.LiveView.ColocatedCSS` and
682+
is usually configured to `"phx-r"`, but it needs to be explicitly enabled in your configuration:
683+
684+
config :phoenix_live_view, root_tag_attribute: "phx-r"
685+
686+
You can also use a different value than `"phx-r"`.
687+
"""
688+
689+
throw_syntax_error!(message, meta)
690+
else
691+
:ok
692+
end
693+
end
694+
695+
defp validate_directive!(module, :root_tag_attribute, other, meta) do
696+
throw_syntax_error!(
697+
"""
698+
expected {name, value} for :root_tag_attribute directive from macro component #{inspect(module)}, got: #{inspect(other)}
699+
700+
name must be a compile-time string, and value must be a compile-time string or true
701+
""",
702+
meta
703+
)
704+
end
705+
706+
defp validate_directive!(module, directive, value, meta) do
707+
throw_syntax_error!(
708+
"unknown directive #{inspect({directive, value})} provided by macro component #{inspect(module)}",
709+
meta
710+
)
711+
end
712+
644713
defp throw_syntax_error!(message, meta) do
645714
throw({:syntax_error, meta.line, meta.column, message})
646715
end

0 commit comments

Comments
 (0)