1252 lines
36 KiB
Elixir
1252 lines
36 KiB
Elixir
defmodule Phoenix.Component.Declarative do
|
|
@moduledoc false
|
|
|
|
## Reserved assigns
|
|
|
|
# This list should only contain attributes that are given to components by engines
|
|
# @socket, @myself, etc. should not be listed here, as they shouldn't be given to
|
|
# function components in the first place
|
|
@reserved_assigns [:__changed__, :__slot__, :__given__, :inner_block]
|
|
|
|
@doc false
|
|
def __reserved__, do: @reserved_assigns
|
|
|
|
## Global
|
|
|
|
@global_prefixes ~w(
|
|
phx-
|
|
aria-
|
|
data-
|
|
)
|
|
@globals ~w(
|
|
accesskey
|
|
alt
|
|
autocapitalize
|
|
autofocus
|
|
class
|
|
contenteditable
|
|
contextmenu
|
|
dir
|
|
draggable
|
|
enterkeyhint
|
|
exportparts
|
|
height
|
|
hidden
|
|
id
|
|
inert
|
|
inputmode
|
|
is
|
|
itemid
|
|
itemprop
|
|
itemref
|
|
itemscope
|
|
itemtype
|
|
lang
|
|
nonce
|
|
onabort
|
|
onautocomplete
|
|
onautocompleteerror
|
|
onblur
|
|
oncancel
|
|
oncanplay
|
|
oncanplaythrough
|
|
onchange
|
|
onclick
|
|
onclose
|
|
oncontextmenu
|
|
oncuechange
|
|
ondblclick
|
|
ondrag
|
|
ondragend
|
|
ondragenter
|
|
ondragleave
|
|
ondragover
|
|
ondragstart
|
|
ondrop
|
|
ondurationchange
|
|
onemptied
|
|
onended
|
|
onerror
|
|
onfocus
|
|
oninput
|
|
oninvalid
|
|
onkeydown
|
|
onkeypress
|
|
onkeyup
|
|
onload
|
|
onloadeddata
|
|
onloadedmetadata
|
|
onloadstart
|
|
onmousedown
|
|
onmouseenter
|
|
onmouseleave
|
|
onmousemove
|
|
onmouseout
|
|
onmouseover
|
|
onmouseup
|
|
onmousewheel
|
|
onpause
|
|
onplay
|
|
onplaying
|
|
onprogress
|
|
onratechange
|
|
onreset
|
|
onresize
|
|
onscroll
|
|
onseeked
|
|
onseeking
|
|
onselect
|
|
onshow
|
|
onsort
|
|
onstalled
|
|
onsubmit
|
|
onsuspend
|
|
ontimeupdate
|
|
ontoggle
|
|
onvolumechange
|
|
onwaiting
|
|
part
|
|
placeholder
|
|
rel
|
|
role
|
|
slot
|
|
spellcheck
|
|
style
|
|
tabindex
|
|
target
|
|
title
|
|
translate
|
|
type
|
|
width
|
|
xml:base
|
|
xml:lang
|
|
)
|
|
|
|
@doc false
|
|
def __global__?(module, name, global_attr) when is_atom(module) and is_binary(name) do
|
|
includes = Keyword.get(global_attr.opts, :include, [])
|
|
|
|
if function_exported?(module, :__global__?, 1) do
|
|
module.__global__?(name) or __global__?(name) or name in includes
|
|
else
|
|
__global__?(name) or name in includes
|
|
end
|
|
end
|
|
|
|
for prefix <- @global_prefixes do
|
|
def __global__?(unquote(prefix) <> _), do: true
|
|
end
|
|
|
|
for name <- @globals do
|
|
def __global__?(unquote(name)), do: true
|
|
end
|
|
|
|
def __global__?(_), do: false
|
|
|
|
## Def overrides
|
|
|
|
@doc false
|
|
defmacro def(expr, body) do
|
|
quote do
|
|
Kernel.def(unquote(annotate_def(:def, expr)), unquote(body))
|
|
end
|
|
end
|
|
|
|
@doc false
|
|
defmacro defp(expr, body) do
|
|
quote do
|
|
Kernel.defp(unquote(annotate_def(:defp, expr)), unquote(body))
|
|
end
|
|
end
|
|
|
|
defp annotate_def(kind, expr) do
|
|
case expr do
|
|
{:when, meta, [left, right]} -> {:when, meta, [annotate_call(kind, left), right]}
|
|
left -> annotate_call(kind, left)
|
|
end
|
|
end
|
|
|
|
defp annotate_call(kind, {name, meta, [{:\\, default_meta, [left, right]}]}),
|
|
do: {name, meta, [{:\\, default_meta, [annotate_arg(kind, left), right]}]}
|
|
|
|
defp annotate_call(kind, {name, meta, [arg]}),
|
|
do: {name, meta, [annotate_arg(kind, arg)]}
|
|
|
|
defp annotate_call(_kind, left),
|
|
do: left
|
|
|
|
defp annotate_arg(kind, {:=, meta, [{name, _, ctx} = var, arg]})
|
|
when is_atom(name) and is_atom(ctx) do
|
|
{:=, meta, [var, quote(do: unquote(__MODULE__).__pattern__!(unquote(kind), unquote(arg)))]}
|
|
end
|
|
|
|
defp annotate_arg(kind, {:=, meta, [arg, {name, _, ctx} = var]})
|
|
when is_atom(name) and is_atom(ctx) do
|
|
{:=, meta, [quote(do: unquote(__MODULE__).__pattern__!(unquote(kind), unquote(arg))), var]}
|
|
end
|
|
|
|
defp annotate_arg(kind, {name, meta, ctx} = var) when is_atom(name) and is_atom(ctx) do
|
|
{:=, meta, [quote(do: unquote(__MODULE__).__pattern__!(unquote(kind), _)), var]}
|
|
end
|
|
|
|
defp annotate_arg(kind, arg) do
|
|
quote(do: unquote(__MODULE__).__pattern__!(unquote(kind), unquote(arg)))
|
|
end
|
|
|
|
## Attrs/slots
|
|
|
|
@doc false
|
|
@valid_opts [:global_prefixes]
|
|
def __setup__(module, opts) do
|
|
{prefixes, invalid_opts} = Keyword.pop(opts, :global_prefixes, [])
|
|
|
|
prefix_matches =
|
|
for prefix <- prefixes do
|
|
unless String.ends_with?(prefix, "-") do
|
|
raise ArgumentError,
|
|
"global prefixes for #{inspect(module)} must end with a dash, got: #{inspect(prefix)}"
|
|
end
|
|
|
|
quote(do: {unquote(prefix) <> _, true})
|
|
end
|
|
|
|
if invalid_opts != [] do
|
|
raise ArgumentError, """
|
|
invalid options passed to #{inspect(__MODULE__)}.
|
|
|
|
The following options are supported: #{inspect(@valid_opts)}, got: #{inspect(invalid_opts)}
|
|
"""
|
|
end
|
|
|
|
Module.register_attribute(module, :__attrs__, accumulate: true)
|
|
Module.register_attribute(module, :__slot_attrs__, accumulate: true)
|
|
Module.register_attribute(module, :__slots__, accumulate: true)
|
|
Module.register_attribute(module, :__slot__, accumulate: false)
|
|
Module.register_attribute(module, :__components_calls__, accumulate: true)
|
|
Module.put_attribute(module, :__components__, %{})
|
|
Module.put_attribute(module, :on_definition, __MODULE__)
|
|
Module.put_attribute(module, :before_compile, __MODULE__)
|
|
|
|
if prefix_matches == [] do
|
|
[]
|
|
else
|
|
prefix_matches ++ [quote(do: {_, false})]
|
|
end
|
|
end
|
|
|
|
@doc false
|
|
def __slot__!(module, name, opts, line, file, block_fun) do
|
|
ensure_used!(module, line, file)
|
|
{doc, opts} = Keyword.pop(opts, :doc, nil)
|
|
|
|
unless is_binary(doc) or is_nil(doc) or doc == false do
|
|
compile_error!(line, file, ":doc must be a string or false, got: #{inspect(doc)}")
|
|
end
|
|
|
|
{required, opts} = Keyword.pop(opts, :required, false)
|
|
{validate_attrs, opts} = Keyword.pop(opts, :validate_attrs, true)
|
|
|
|
unless is_boolean(required) do
|
|
compile_error!(line, file, ":required must be a boolean, got: #{inspect(required)}")
|
|
end
|
|
|
|
Module.put_attribute(module, :__slot__, name)
|
|
|
|
slot_attrs =
|
|
try do
|
|
block_fun.()
|
|
module |> Module.get_attribute(:__slot_attrs__) |> Enum.reverse()
|
|
after
|
|
Module.put_attribute(module, :__slot__, nil)
|
|
Module.delete_attribute(module, :__slot_attrs__)
|
|
end
|
|
|
|
slot = %{
|
|
name: name,
|
|
required: required,
|
|
opts: opts,
|
|
doc: doc,
|
|
line: line,
|
|
attrs: slot_attrs,
|
|
validate_attrs: validate_attrs
|
|
}
|
|
|
|
validate_slot!(module, slot, line, file)
|
|
Module.put_attribute(module, :__slots__, slot)
|
|
:ok
|
|
end
|
|
|
|
defp validate_slot!(module, slot, line, file) do
|
|
slots = Module.get_attribute(module, :__slots__) || []
|
|
|
|
if Enum.find(slots, &(&1.name == slot.name)) do
|
|
compile_error!(line, file, """
|
|
a duplicate slot with name #{inspect(slot.name)} already exists\
|
|
""")
|
|
end
|
|
|
|
if slot.name == :inner_block and slot.attrs != [] do
|
|
compile_error!(line, file, """
|
|
cannot define attributes in a slot with name #{inspect(slot.name)}
|
|
""")
|
|
end
|
|
end
|
|
|
|
@doc false
|
|
def __attr__!(module, name, type, opts, line, file) when is_atom(name) and is_list(opts) do
|
|
ensure_used!(module, line, file)
|
|
slot = Module.get_attribute(module, :__slot__)
|
|
|
|
if name == :inner_block do
|
|
compile_error!(
|
|
line,
|
|
file,
|
|
"cannot define attribute called :inner_block. Maybe you wanted to use `slot` instead?"
|
|
)
|
|
end
|
|
|
|
if type == :global && slot do
|
|
compile_error!(line, file, "cannot define :global slot attributes")
|
|
end
|
|
|
|
if type == :global and Keyword.has_key?(opts, :required) do
|
|
compile_error!(line, file, "global attributes do not support the :required option")
|
|
end
|
|
|
|
if type == :global and Keyword.has_key?(opts, :values) do
|
|
compile_error!(line, file, "global attributes do not support the :values option")
|
|
end
|
|
|
|
if type == :global and Keyword.has_key?(opts, :examples) do
|
|
compile_error!(line, file, "global attributes do not support the :examples option")
|
|
end
|
|
|
|
if type != :global and Keyword.has_key?(opts, :include) do
|
|
compile_error!(line, file, ":include is only supported for :global attributes")
|
|
end
|
|
|
|
{doc, opts} = Keyword.pop(opts, :doc, nil)
|
|
|
|
unless is_binary(doc) or is_nil(doc) or doc == false do
|
|
compile_error!(line, file, ":doc must be a string or false, got: #{inspect(doc)}")
|
|
end
|
|
|
|
{required, opts} = Keyword.pop(opts, :required, false)
|
|
|
|
unless is_boolean(required) do
|
|
compile_error!(line, file, ":required must be a boolean, got: #{inspect(required)}")
|
|
end
|
|
|
|
if required and Keyword.has_key?(opts, :default) do
|
|
compile_error!(line, file, "only one of :required or :default must be given")
|
|
end
|
|
|
|
key = if slot, do: :__slot_attrs__, else: :__attrs__
|
|
type = validate_attr_type!(module, key, slot, name, type, line, file)
|
|
validate_attr_opts!(slot, name, opts, line, file)
|
|
|
|
if Keyword.has_key?(opts, :values) and Keyword.has_key?(opts, :examples) do
|
|
compile_error!(line, file, "only one of :values or :examples must be given")
|
|
end
|
|
|
|
if Keyword.has_key?(opts, :values) do
|
|
validate_attr_values!(slot, name, type, opts[:values], line, file)
|
|
end
|
|
|
|
if Keyword.has_key?(opts, :examples) do
|
|
validate_attr_examples!(slot, name, type, opts[:examples], line, file)
|
|
end
|
|
|
|
if Keyword.has_key?(opts, :default) do
|
|
validate_attr_default!(slot, name, type, opts, line, file)
|
|
end
|
|
|
|
attr = %{
|
|
slot: slot,
|
|
name: name,
|
|
type: type,
|
|
required: required,
|
|
opts: opts,
|
|
doc: doc,
|
|
line: line
|
|
}
|
|
|
|
Module.put_attribute(module, key, attr)
|
|
:ok
|
|
end
|
|
|
|
@builtin_types [:boolean, :integer, :float, :string, :atom, :list, :map, :global]
|
|
@valid_types [:any] ++ @builtin_types
|
|
|
|
defp validate_attr_type!(module, key, slot, name, type, line, file) when is_atom(type) do
|
|
attrs = Module.get_attribute(module, key) || []
|
|
|
|
cond do
|
|
Enum.find(attrs, fn attr -> attr.name == name end) ->
|
|
compile_error!(line, file, """
|
|
a duplicate attribute with name #{attr_slot(name, slot)} already exists\
|
|
""")
|
|
|
|
existing = type == :global && Enum.find(attrs, fn attr -> attr.type == :global end) ->
|
|
compile_error!(line, file, """
|
|
cannot define :global attribute #{inspect(name)} because one \
|
|
is already defined as #{attr_slot(existing.name, slot)}. \
|
|
Only a single :global attribute may be defined\
|
|
""")
|
|
|
|
true ->
|
|
:ok
|
|
end
|
|
|
|
case Atom.to_string(type) do
|
|
"Elixir." <> _ -> {:struct, type}
|
|
_ when type in @valid_types -> type
|
|
_ -> bad_type!(slot, name, type, line, file)
|
|
end
|
|
end
|
|
|
|
defp validate_attr_type!(_module, _key, slot, name, type, line, file) do
|
|
bad_type!(slot, name, type, line, file)
|
|
end
|
|
|
|
defp bad_type!(slot, name, type, line, file) do
|
|
compile_error!(line, file, """
|
|
invalid type #{inspect(type)} for attr #{attr_slot(name, slot)}. \
|
|
The following types are supported:
|
|
|
|
* any Elixir struct, such as URI, MyApp.User, etc
|
|
* one of #{Enum.map_join(@builtin_types, ", ", &inspect/1)}
|
|
* :any for all other types
|
|
""")
|
|
end
|
|
|
|
defp attr_slot(name, nil), do: "#{inspect(name)}"
|
|
defp attr_slot(name, slot), do: "#{inspect(name)} in slot #{inspect(slot)}"
|
|
|
|
defp validate_attr_default!(slot, name, type, opts, line, file) do
|
|
case {opts[:default], opts[:values]} do
|
|
{default, nil} ->
|
|
unless valid_value?(type, default) do
|
|
bad_default!(slot, name, type, default, line, file)
|
|
end
|
|
|
|
{default, values} ->
|
|
unless default in values do
|
|
compile_error!(line, file, """
|
|
expected the default value for attr #{attr_slot(name, slot)} to be one of #{inspect(values)}, \
|
|
got: #{inspect(default)}
|
|
""")
|
|
end
|
|
end
|
|
end
|
|
|
|
defp bad_default!(slot, name, type, default, line, file) do
|
|
compile_error!(line, file, """
|
|
expected the default value for attr #{attr_slot(name, slot)} to be #{type_with_article(type)}, \
|
|
got: #{inspect(default)}
|
|
""")
|
|
end
|
|
|
|
defp validate_attr_values!(slot, name, type, values, line, file) do
|
|
unless is_enumerable(values) and not Enum.empty?(values) do
|
|
compile_error!(line, file, """
|
|
:values must be a non-empty enumerable, got: #{inspect(values)}
|
|
""")
|
|
end
|
|
|
|
for value <- values,
|
|
not valid_value?(type, value),
|
|
do: bad_value!(slot, name, type, value, line, file)
|
|
end
|
|
|
|
defp is_enumerable(values) do
|
|
Enumerable.impl_for(values) != nil
|
|
end
|
|
|
|
defp bad_value!(slot, name, type, value, line, file) do
|
|
compile_error!(line, file, """
|
|
expected the values for attr #{attr_slot(name, slot)} to be #{type_with_article(type)}, \
|
|
got: #{inspect(value)}
|
|
""")
|
|
end
|
|
|
|
defp validate_attr_examples!(slot, name, type, examples, line, file) do
|
|
unless is_list(examples) and not Enum.empty?(examples) do
|
|
compile_error!(line, file, """
|
|
:examples must be a non-empty list, got: #{inspect(examples)}
|
|
""")
|
|
end
|
|
|
|
for example <- examples,
|
|
not valid_value?(type, example),
|
|
do: bad_example!(slot, name, type, example, line, file)
|
|
end
|
|
|
|
defp bad_example!(slot, name, type, example, line, file) do
|
|
compile_error!(line, file, """
|
|
expected the examples for attr #{attr_slot(name, slot)} to be #{type_with_article(type)}, \
|
|
got: #{inspect(example)}
|
|
""")
|
|
end
|
|
|
|
defp valid_value?(_type, nil), do: true
|
|
defp valid_value?(:any, _value), do: true
|
|
defp valid_value?(:string, value), do: is_binary(value)
|
|
defp valid_value?(:atom, value), do: is_atom(value)
|
|
defp valid_value?(:boolean, value), do: is_boolean(value)
|
|
defp valid_value?(:integer, value), do: is_integer(value)
|
|
defp valid_value?(:float, value), do: is_float(value)
|
|
defp valid_value?(:list, value), do: is_list(value)
|
|
defp valid_value?({:struct, mod}, value), do: is_struct(value, mod)
|
|
defp valid_value?(_type, _value), do: true
|
|
|
|
defp validate_attr_opts!(slot, name, opts, line, file) do
|
|
for {key, _} <- opts, message = invalid_attr_message(key, slot) do
|
|
compile_error!(line, file, """
|
|
invalid option #{inspect(key)} for attr #{attr_slot(name, slot)}. #{message}\
|
|
""")
|
|
end
|
|
end
|
|
|
|
defp invalid_attr_message(:include, inc) when is_list(inc) or is_nil(inc), do: nil
|
|
|
|
defp invalid_attr_message(:include, other),
|
|
do: "include only supports a list of attributes, got: #{inspect(other)}"
|
|
|
|
defp invalid_attr_message(:default, nil), do: nil
|
|
|
|
defp invalid_attr_message(:default, _),
|
|
do:
|
|
":default is not supported inside slot attributes, " <>
|
|
"instead use Map.get/3 with a default value when accessing a slot attribute"
|
|
|
|
defp invalid_attr_message(:required, _), do: nil
|
|
defp invalid_attr_message(:values, _), do: nil
|
|
defp invalid_attr_message(:examples, _), do: nil
|
|
|
|
defp invalid_attr_message(_key, nil),
|
|
do: "The supported options are: [:required, :default, :values, :examples]"
|
|
|
|
defp invalid_attr_message(_key, _slot),
|
|
do: "The supported options inside slots are: [:required]"
|
|
|
|
defp compile_error!(line, file, msg) do
|
|
raise CompileError, line: line, file: file, description: msg
|
|
end
|
|
|
|
defmacro __pattern__!(kind, arg) do
|
|
{name, 1} = __CALLER__.function
|
|
{_slots, attrs} = register_component!(kind, __CALLER__, name, true)
|
|
|
|
fields =
|
|
for %{name: name, required: true, type: {:struct, struct}} <- attrs do
|
|
{name, quote(do: %unquote(struct){})}
|
|
end
|
|
|
|
if fields == [] do
|
|
arg
|
|
else
|
|
quote(do: %{unquote_splicing(fields)} = unquote(arg))
|
|
end
|
|
end
|
|
|
|
@doc false
|
|
def __on_definition__(env, kind, name, args, _guards, body) do
|
|
check? = not String.starts_with?(to_string(name), "__")
|
|
|
|
cond do
|
|
check? and length(args) == 1 and body == nil ->
|
|
register_component!(kind, env, name, false)
|
|
|
|
check? ->
|
|
attrs = pop_attrs(env)
|
|
|
|
validate_misplaced_attrs!(attrs, env.file, fn ->
|
|
case length(args) do
|
|
1 ->
|
|
"could not define attributes for function #{name}/1. " <>
|
|
"Please make sure that you have `use Phoenix.Component` and that the function has no default arguments"
|
|
|
|
arity ->
|
|
"cannot declare attributes for function #{name}/#{arity}. Components must be functions with arity 1"
|
|
end
|
|
end)
|
|
|
|
slots = pop_slots(env)
|
|
|
|
validate_misplaced_slots!(slots, env.file, fn ->
|
|
case length(args) do
|
|
1 ->
|
|
"could not define slots for function #{name}/1. " <>
|
|
"Components cannot be dynamically defined or have default arguments"
|
|
|
|
arity ->
|
|
"cannot declare slots for function #{name}/#{arity}. Components must be functions with arity 1"
|
|
end
|
|
end)
|
|
|
|
true ->
|
|
:ok
|
|
end
|
|
end
|
|
|
|
@after_verify_supported Version.match?(System.version(), ">= 1.14.0-dev")
|
|
|
|
@doc false
|
|
defmacro __before_compile__(env) do
|
|
attrs = pop_attrs(env)
|
|
|
|
validate_misplaced_attrs!(attrs, env.file, fn ->
|
|
"cannot define attributes without a related function component"
|
|
end)
|
|
|
|
slots = pop_slots(env)
|
|
|
|
validate_misplaced_slots!(slots, env.file, fn ->
|
|
"cannot define slots without a related function component"
|
|
end)
|
|
|
|
components = Module.get_attribute(env.module, :__components__)
|
|
components_calls = Module.get_attribute(env.module, :__components_calls__) |> Enum.reverse()
|
|
|
|
names_and_defs =
|
|
for {name, %{kind: kind, attrs: attrs, slots: slots, line: line}} <- components do
|
|
attr_defaults =
|
|
for %{name: name, required: false, opts: opts} <- attrs,
|
|
Keyword.has_key?(opts, :default),
|
|
do: {name, Macro.escape(opts[:default])}
|
|
|
|
slot_defaults =
|
|
for %{name: name, required: false} <- slots do
|
|
{name, []}
|
|
end
|
|
|
|
defaults = attr_defaults ++ slot_defaults
|
|
|
|
{global_name, global_default} =
|
|
case Enum.find(attrs, fn attr -> attr.type == :global end) do
|
|
%{name: name, opts: opts} -> {name, Macro.escape(Keyword.get(opts, :default, %{}))}
|
|
nil -> {nil, nil}
|
|
end
|
|
|
|
attr_names = for(attr <- attrs, do: attr.name)
|
|
slot_names = for(slot <- slots, do: slot.name)
|
|
known_keys = attr_names ++ slot_names ++ @reserved_assigns
|
|
|
|
def_body =
|
|
if global_name do
|
|
quote do
|
|
{assigns, caller_globals} = Map.split(assigns, unquote(known_keys))
|
|
|
|
globals =
|
|
case assigns do
|
|
%{unquote(global_name) => explicit_global_assign} -> explicit_global_assign
|
|
%{} -> Map.merge(unquote(global_default), caller_globals)
|
|
end
|
|
|
|
merged =
|
|
%{unquote_splicing(defaults)}
|
|
|> Map.merge(assigns)
|
|
|> Map.put(:__given__, assigns)
|
|
|
|
super(Phoenix.Component.assign(merged, unquote(global_name), globals))
|
|
end
|
|
else
|
|
quote do
|
|
merged =
|
|
%{unquote_splicing(defaults)}
|
|
|> Map.merge(assigns)
|
|
|> Map.put(:__given__, assigns)
|
|
|
|
super(merged)
|
|
end
|
|
end
|
|
|
|
merge =
|
|
quote line: line do
|
|
Kernel.unquote(kind)(unquote(name)(assigns)) do
|
|
unquote(def_body)
|
|
end
|
|
end
|
|
|
|
{{name, 1}, merge}
|
|
end
|
|
|
|
{names, defs} = Enum.unzip(names_and_defs)
|
|
|
|
overridable =
|
|
if names != [] do
|
|
quote do
|
|
defoverridable unquote(names)
|
|
end
|
|
end
|
|
|
|
def_components_ast =
|
|
quote do
|
|
def __components__() do
|
|
unquote(Macro.escape(components))
|
|
end
|
|
end
|
|
|
|
def_components_calls_ast =
|
|
if components_calls != [] and @after_verify_supported do
|
|
quote do
|
|
@after_verify {__MODULE__, :__phoenix_component_verify__}
|
|
|
|
@doc false
|
|
def __phoenix_component_verify__(module) do
|
|
unquote(__MODULE__).__verify__(module, unquote(Macro.escape(components_calls)))
|
|
end
|
|
end
|
|
end
|
|
|
|
{:__block__, [], [def_components_ast, def_components_calls_ast, overridable | defs]}
|
|
end
|
|
|
|
defp register_component!(kind, env, name, check_if_defined?) do
|
|
slots = pop_slots(env)
|
|
attrs = pop_attrs(env)
|
|
|
|
cond do
|
|
slots != [] or attrs != [] ->
|
|
check_if_defined? and raise_if_function_already_defined!(env, name, slots, attrs)
|
|
register_component_doc(env, kind, slots, attrs)
|
|
|
|
for %{name: slot_name, line: line} <- slots,
|
|
Enum.find(attrs, &(&1.name == slot_name)) do
|
|
compile_error!(line, env.file, """
|
|
cannot define a slot with name #{inspect(slot_name)}, as an attribute with that name already exists\
|
|
""")
|
|
end
|
|
|
|
components =
|
|
env.module
|
|
|> Module.get_attribute(:__components__)
|
|
# Sort by name as this is used when they are validated
|
|
|> Map.put(name, %{
|
|
kind: kind,
|
|
attrs: Enum.sort_by(attrs, & &1.name),
|
|
slots: Enum.sort_by(slots, & &1.name),
|
|
line: env.line
|
|
})
|
|
|
|
Module.put_attribute(env.module, :__components__, components)
|
|
Module.put_attribute(env.module, :__last_component__, name)
|
|
{slots, attrs}
|
|
|
|
Module.get_attribute(env.module, :__last_component__) == name ->
|
|
%{slots: slots, attrs: attrs} = Module.get_attribute(env.module, :__components__)[name]
|
|
{slots, attrs}
|
|
|
|
true ->
|
|
{[], []}
|
|
end
|
|
end
|
|
|
|
# Documentation handling
|
|
|
|
defp register_component_doc(env, :def, slots, attrs) do
|
|
case Module.get_attribute(env.module, :doc) do
|
|
{_line, false} ->
|
|
:ok
|
|
|
|
{line, doc} ->
|
|
Module.put_attribute(env.module, :doc, {line, build_component_doc(doc, slots, attrs)})
|
|
|
|
nil ->
|
|
Module.put_attribute(env.module, :doc, {env.line, build_component_doc(slots, attrs)})
|
|
end
|
|
end
|
|
|
|
defp register_component_doc(_env, :defp, _slots, _attrs) do
|
|
:ok
|
|
end
|
|
|
|
defp build_component_doc(doc \\ "", slots, attrs) do
|
|
[left | right] = String.split(doc, "[INSERT LVATTRDOCS]")
|
|
|
|
IO.iodata_to_binary([
|
|
build_left_doc(left),
|
|
build_component_docs(slots, attrs),
|
|
build_right_doc(right)
|
|
])
|
|
end
|
|
|
|
defp build_left_doc("") do
|
|
[""]
|
|
end
|
|
|
|
defp build_left_doc(left) do
|
|
[left, ?\n]
|
|
end
|
|
|
|
defp build_component_docs(slots, attrs) do
|
|
case {slots, attrs} do
|
|
{[], []} ->
|
|
[]
|
|
|
|
{slots, [] = _attrs} ->
|
|
[build_slots_docs(slots)]
|
|
|
|
{[] = _slots, attrs} ->
|
|
[build_attrs_docs(attrs)]
|
|
|
|
{slots, attrs} ->
|
|
[build_attrs_docs(attrs), ?\n, build_slots_docs(slots)]
|
|
end
|
|
end
|
|
|
|
defp build_slots_docs(slots) do
|
|
[
|
|
"## Slots\n",
|
|
for slot <- slots, slot.doc != false, into: [] do
|
|
slot_attrs =
|
|
for slot_attr <- slot.attrs,
|
|
slot_attr.doc != false,
|
|
slot_attr.slot == slot.name,
|
|
do: slot_attr
|
|
|
|
[
|
|
"\n* ",
|
|
build_slot_name(slot),
|
|
build_slot_required(slot),
|
|
build_slot_doc(slot, slot_attrs)
|
|
]
|
|
end
|
|
]
|
|
end
|
|
|
|
defp build_attrs_docs(attrs) do
|
|
[
|
|
"## Attributes\n",
|
|
for attr <- attrs, attr.doc != false and attr.type != :global do
|
|
[
|
|
"\n* ",
|
|
build_attr_name(attr),
|
|
build_attr_type(attr),
|
|
build_attr_required(attr),
|
|
build_hyphen(attr),
|
|
build_attr_doc_and_default(attr, " "),
|
|
build_attr_values_or_examples(attr)
|
|
]
|
|
end,
|
|
# global always goes at the end
|
|
case Enum.find(attrs, &(&1.type === :global)) do
|
|
nil -> []
|
|
attr -> build_attr_doc_and_default(attr, " ")
|
|
end
|
|
]
|
|
end
|
|
|
|
defp build_slot_name(%{name: name}) do
|
|
["`", Atom.to_string(name), "`"]
|
|
end
|
|
|
|
defp build_slot_doc(%{doc: nil}, []) do
|
|
[]
|
|
end
|
|
|
|
defp build_slot_doc(%{doc: doc}, []) do
|
|
[" - ", build_doc(doc, " ", false)]
|
|
end
|
|
|
|
defp build_slot_doc(%{doc: nil}, slot_attrs) do
|
|
[" - Accepts attributes:\n", build_slot_attrs_docs(slot_attrs)]
|
|
end
|
|
|
|
defp build_slot_doc(%{doc: doc}, slot_attrs) do
|
|
[
|
|
" - ",
|
|
build_doc(doc, " ", true),
|
|
"Accepts attributes:\n",
|
|
build_slot_attrs_docs(slot_attrs)
|
|
]
|
|
end
|
|
|
|
defp build_slot_attrs_docs(slot_attrs) do
|
|
for slot_attr <- slot_attrs do
|
|
[
|
|
"\n * ",
|
|
build_attr_name(slot_attr),
|
|
build_attr_type(slot_attr),
|
|
build_attr_required(slot_attr),
|
|
build_hyphen(slot_attr),
|
|
build_attr_doc_and_default(slot_attr, " "),
|
|
build_attr_values_or_examples(slot_attr)
|
|
]
|
|
end
|
|
end
|
|
|
|
defp build_slot_required(%{required: true}) do
|
|
[" (required)"]
|
|
end
|
|
|
|
defp build_slot_required(_slot) do
|
|
[]
|
|
end
|
|
|
|
defp build_attr_name(%{name: name}) do
|
|
["`", Atom.to_string(name), "` "]
|
|
end
|
|
|
|
defp build_attr_type(%{type: {:struct, type}}) do
|
|
["(`", inspect(type), "`)"]
|
|
end
|
|
|
|
defp build_attr_type(%{type: type}) do
|
|
["(`", inspect(type), "`)"]
|
|
end
|
|
|
|
defp build_attr_required(%{required: true}) do
|
|
[" (required)"]
|
|
end
|
|
|
|
defp build_attr_required(_attr) do
|
|
[]
|
|
end
|
|
|
|
defp build_attr_doc_and_default(%{doc: doc, type: :global, opts: opts}, indent) do
|
|
[
|
|
"\n* Global attributes are accepted.",
|
|
if(doc, do: [" ", build_doc(doc, indent, false)], else: []),
|
|
case Keyword.get(opts, :include) do
|
|
inc when is_list(inc) and inc != [] ->
|
|
[" ", "Supports all globals plus:", " ", build_literal(inc), "."]
|
|
|
|
_ ->
|
|
[]
|
|
end
|
|
]
|
|
end
|
|
|
|
defp build_attr_doc_and_default(%{doc: doc, opts: opts}, indent) do
|
|
case Keyword.fetch(opts, :default) do
|
|
{:ok, default} ->
|
|
if doc do
|
|
[build_doc(doc, indent, true), "Defaults to ", build_literal(default), "."]
|
|
else
|
|
["Defaults to ", build_literal(default), "."]
|
|
end
|
|
|
|
:error ->
|
|
if doc, do: [build_doc(doc, indent, false)], else: []
|
|
end
|
|
end
|
|
|
|
defp build_doc(doc, indent, text_after?) do
|
|
doc = String.trim(doc)
|
|
[head | tail] = String.split(doc, ["\r\n", "\n"])
|
|
dot = if String.ends_with?(doc, "."), do: [], else: [?.]
|
|
|
|
tail =
|
|
Enum.map(tail, fn
|
|
"" -> "\n"
|
|
other -> [?\n, indent | other]
|
|
end)
|
|
|
|
case tail do
|
|
# Single line
|
|
[] when text_after? ->
|
|
[[head | tail], dot, ?\s]
|
|
|
|
[] ->
|
|
[[head | tail], dot]
|
|
|
|
# Multi-line
|
|
_ when text_after? ->
|
|
[[head | tail], "\n\n", indent]
|
|
|
|
_ ->
|
|
[[head | tail], "\n"]
|
|
end
|
|
end
|
|
|
|
defp build_attr_values_or_examples(%{opts: [values: values]}) do
|
|
["Must be one of ", build_literals_list(values, "or"), ?.]
|
|
end
|
|
|
|
defp build_attr_values_or_examples(%{opts: [examples: examples]}) do
|
|
["Examples include ", build_literals_list(examples, "and"), ?.]
|
|
end
|
|
|
|
defp build_attr_values_or_examples(_attr) do
|
|
[]
|
|
end
|
|
|
|
defp build_literals_list([literal], _condition) do
|
|
[build_literal(literal)]
|
|
end
|
|
|
|
defp build_literals_list(literals, condition) do
|
|
literals
|
|
|> Enum.map_intersperse(", ", &build_literal/1)
|
|
|> List.insert_at(-2, [condition, " "])
|
|
end
|
|
|
|
defp build_literal(literal) do
|
|
[?`, inspect(literal, charlists: :as_list), ?`]
|
|
end
|
|
|
|
defp build_hyphen(%{doc: doc}) when is_binary(doc) do
|
|
[" - "]
|
|
end
|
|
|
|
defp build_hyphen(%{opts: []}) do
|
|
[]
|
|
end
|
|
|
|
defp build_hyphen(%{opts: _opts}) do
|
|
[" - "]
|
|
end
|
|
|
|
defp build_right_doc("") do
|
|
[]
|
|
end
|
|
|
|
defp build_right_doc(right) do
|
|
[?\n, right]
|
|
end
|
|
|
|
defp validate_misplaced_attrs!(attrs, file, message_fun) do
|
|
with [%{line: first_attr_line} | _] <- attrs do
|
|
compile_error!(first_attr_line, file, message_fun.())
|
|
end
|
|
end
|
|
|
|
defp validate_misplaced_slots!(slots, file, message_fun) do
|
|
with [%{line: first_slot_line} | _] <- slots do
|
|
compile_error!(first_slot_line, file, message_fun.())
|
|
end
|
|
end
|
|
|
|
defp pop_attrs(env) do
|
|
slots = Module.delete_attribute(env.module, :__attrs__) || []
|
|
Enum.reverse(slots)
|
|
end
|
|
|
|
defp pop_slots(env) do
|
|
slots = Module.delete_attribute(env.module, :__slots__) || []
|
|
Enum.reverse(slots)
|
|
end
|
|
|
|
defp raise_if_function_already_defined!(env, name, slots, attrs) do
|
|
if Module.defines?(env.module, {name, 1}) do
|
|
{:v1, _, meta, _} = Module.get_definition(env.module, {name, 1})
|
|
|
|
with [%{line: first_attr_line} | _] <- attrs do
|
|
compile_error!(first_attr_line, env.file, """
|
|
attributes must be defined before the first function clause at line #{meta[:line]}
|
|
""")
|
|
end
|
|
|
|
with [%{line: first_slot_line} | _] <- slots do
|
|
compile_error!(first_slot_line, env.file, """
|
|
slots must be defined before the first function clause at line #{meta[:line]}
|
|
""")
|
|
end
|
|
end
|
|
end
|
|
|
|
# Verification
|
|
|
|
@doc false
|
|
def __verify__(module, component_calls) do
|
|
for %{component: {submod, fun}} = call <- component_calls,
|
|
function_exported?(submod, :__components__, 0),
|
|
component = submod.__components__()[fun],
|
|
do: verify(module, call, component)
|
|
|
|
:ok
|
|
end
|
|
|
|
defp verify(
|
|
caller_module,
|
|
%{slots: slots, attrs: attrs, root: root} = call,
|
|
%{slots: slots_defs, attrs: attrs_defs} = _component
|
|
) do
|
|
{attrs, global_attr} =
|
|
Enum.reduce(attrs_defs, {attrs, nil}, fn attr_def, {attrs, global_attr} ->
|
|
%{name: name, required: required, type: type, opts: opts} = attr_def
|
|
attr_values = Keyword.get(opts, :values, nil)
|
|
{value, attrs} = Map.pop(attrs, name)
|
|
|
|
case {type, value} do
|
|
# missing required attr
|
|
{_type, nil} when not root and required ->
|
|
message = "missing required attribute \"#{name}\" for component #{component_fa(call)}"
|
|
warn(message, call.file, call.line)
|
|
|
|
# missing optional attr, or dynamic attr
|
|
{_type, nil} when root or not required ->
|
|
:ok
|
|
|
|
# global attrs cannot be directly used
|
|
{:global, {line, _column, _type_value}} ->
|
|
message =
|
|
"global attribute \"#{name}\" in component #{component_fa(call)} may not be provided directly"
|
|
|
|
warn(message, call.file, line)
|
|
|
|
# attrs must be one of values
|
|
{_type, {line, _column, {_, type_value}}} when not is_nil(attr_values) ->
|
|
unless type_value in attr_values do
|
|
message =
|
|
"attribute \"#{name}\" in component #{component_fa(call)} must be one of #{inspect(attr_values)}, got: #{inspect(type_value)}"
|
|
|
|
warn(message, call.file, line)
|
|
end
|
|
|
|
# attrs must be of the declared type
|
|
{type, {line, _column, type_value}} ->
|
|
if value_ast_to_string = type_mismatch(type, type_value) do
|
|
message =
|
|
"attribute \"#{name}\" in component #{component_fa(call)} must be #{type_with_article(type)}, got: " <>
|
|
value_ast_to_string
|
|
|
|
[warn(message, call.file, line)]
|
|
end
|
|
end
|
|
|
|
{attrs, global_attr || (type == :global and attr_def)}
|
|
end)
|
|
|
|
for {name, {line, _column, _type_value}} <- attrs,
|
|
!(global_attr && __global__?(caller_module, Atom.to_string(name), global_attr)) do
|
|
message = "undefined attribute \"#{name}\" for component #{component_fa(call)}"
|
|
warn(message, call.file, line)
|
|
end
|
|
|
|
undefined_slots =
|
|
Enum.reduce(slots_defs, slots, fn slot_def, slots ->
|
|
%{name: slot_name, required: required, attrs: attrs, validate_attrs: validate_attrs} = slot_def
|
|
{slot_values, slots} = Map.pop(slots, slot_name)
|
|
|
|
case slot_values do
|
|
# missing required slot
|
|
nil when required ->
|
|
message = "missing required slot \"#{slot_name}\" for component #{component_fa(call)}"
|
|
warn(message, call.file, call.line)
|
|
|
|
# missing optional slot
|
|
nil ->
|
|
:ok
|
|
|
|
# slot with attributes
|
|
_ ->
|
|
slot_attr_defs = Enum.into(attrs, %{}, &{&1.name, &1})
|
|
required_attrs = for {attr_name, %{required: true}} <- slot_attr_defs, do: attr_name
|
|
|
|
for %{attrs: slot_attrs, line: slot_line, root: false} <- slot_values,
|
|
attr_name <- required_attrs,
|
|
not Map.has_key?(slot_attrs, attr_name) do
|
|
message =
|
|
"missing required attribute \"#{attr_name}\" in slot \"#{slot_name}\" " <>
|
|
"for component #{component_fa(call)}"
|
|
|
|
warn(message, call.file, slot_line)
|
|
end
|
|
|
|
for %{attrs: slot_attrs} <- slot_values,
|
|
{attr_name, {line, _column, type_value}} <- slot_attrs do
|
|
case slot_attr_defs do
|
|
# slots cannot accept global attributes
|
|
%{^attr_name => %{type: :global}} ->
|
|
message =
|
|
"global attribute \"#{attr_name}\" in slot \"#{slot_name}\" " <>
|
|
"for component #{component_fa(call)} may not be provided directly"
|
|
|
|
warn(message, call.file, line)
|
|
|
|
# slot attrs must be one of values
|
|
%{^attr_name => %{type: _type, opts: [values: attr_values]}}
|
|
when is_tuple(type_value) and tuple_size(type_value) == 2 ->
|
|
{_, attr_value} = type_value
|
|
|
|
unless attr_value in attr_values do
|
|
message =
|
|
"attribute \"#{attr_name}\" in slot \"#{slot_name}\" " <>
|
|
"for component #{component_fa(call)} must be one of #{inspect(attr_values)}, got: " <>
|
|
inspect(attr_value)
|
|
|
|
warn(message, call.file, line)
|
|
end
|
|
|
|
# slot attrs must be of the declared type
|
|
%{^attr_name => %{type: type}} ->
|
|
if value_ast_to_string = type_mismatch(type, type_value) do
|
|
message =
|
|
"attribute \"#{attr_name}\" in slot \"#{slot_name}\" " <>
|
|
"for component #{component_fa(call)} must be #{type_with_article(type)}, got: " <>
|
|
value_ast_to_string
|
|
|
|
warn(message, call.file, line)
|
|
end
|
|
|
|
# undefined slot attr
|
|
%{} ->
|
|
cond do
|
|
attr_name == :inner_block -> :ok
|
|
|
|
attrs == [] and not validate_attrs -> :ok
|
|
|
|
true ->
|
|
message =
|
|
"undefined attribute \"#{attr_name}\" in slot \"#{slot_name}\" " <>
|
|
"for component #{component_fa(call)}"
|
|
|
|
warn(message, call.file, line)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
slots
|
|
end)
|
|
|
|
for {slot_name, slot_values} <- undefined_slots,
|
|
%{line: line} <- slot_values,
|
|
not implicit_inner_block?(slot_name, slots_defs) do
|
|
message = "undefined slot \"#{slot_name}\" for component #{component_fa(call)}"
|
|
warn(message, call.file, line)
|
|
end
|
|
|
|
:ok
|
|
end
|
|
|
|
defp implicit_inner_block?(slot_name, slots_defs) do
|
|
slot_name == :inner_block and length(slots_defs) > 0
|
|
end
|
|
|
|
defp type_mismatch(:any, _type_value), do: nil
|
|
defp type_mismatch(_type, :any), do: nil
|
|
defp type_mismatch(type, {type, _value}), do: nil
|
|
defp type_mismatch(:atom, {:boolean, _value}), do: nil
|
|
defp type_mismatch({:struct, _}, {:map, {:%{}, _, [{:|, _, [_, _]}]}}), do: nil
|
|
defp type_mismatch(_type, {_, value}), do: Macro.to_string(value)
|
|
|
|
defp component_fa(%{component: {mod, fun}}) do
|
|
"#{inspect(mod)}.#{fun}/1"
|
|
end
|
|
|
|
## Shared helpers
|
|
|
|
defp type_with_article({:struct, struct}), do: "a #{inspect(struct)} struct"
|
|
defp type_with_article(type) when type in [:atom, :integer], do: "an #{inspect(type)}"
|
|
defp type_with_article(type), do: "a #{inspect(type)}"
|
|
|
|
# TODO: Provide column information in error messages
|
|
defp warn(message, file, line) do
|
|
IO.warn(message, file: file, line: line)
|
|
end
|
|
|
|
defp ensure_used!(module, line, file) do
|
|
if !Module.get_attribute(module, :__attrs__) do
|
|
compile_error!(
|
|
line,
|
|
file,
|
|
"you must `use Phoenix.Component` to declare attributes. It is currently only imported."
|
|
)
|
|
end
|
|
end
|
|
end
|