defmodule Guppy.Component do
@moduledoc """
Compile-time Guppy template support.
`use Guppy.Component` imports the `~GUI` sigil and common window assign/update helpers,
compiling a restricted HEEx-style template syntax directly to Guppy IR.
The current template vocabulary intentionally matches Guppy's real IR surface:
- `<div>`
- `<text>`
- `<rich_text />`
- `<button>`
- `<checkbox />`
- `<radio />`
- `<scroll>`
- `<uniform_list />`
- `<list />`
- `<data_table />`
- `<tree />`
- `<canvas />`
- `<popover>`
- `<select />`
- `<image />`
- `<icon />`
- `<spacer />`
- `<text_input />`
- `<textarea />`
It also supports first-pass function components:
- dotted local tags like `<.my_component>` call a function in the current module with an assigns map
- remote module tags call `render/1` on that module
- nested component content is passed as `@children`
- `prop/4` can declare required props, defaults, and simple validations
Expressions use `{...}` syntax. Assign lookups use `@name`, resolving from an
`assigns` map in scope when it is a map, otherwise from a `Guppy.Window` value
named `window`.
"""
defmacro __using__(_opts) do
quote do
Module.register_attribute(__MODULE__, :guppy_component_prop_declarations, accumulate: true)
@before_compile Guppy.Component
import Guppy.Component, only: [sigil_GUI: 2, prop: 3, prop: 4]
import Guppy.Window,
only: [assign: 2, assign: 3, update: 3, put_private: 3, put_window_opts: 2]
import Guppy.App, only: [theme_color: 1, theme_color!: 1, theme_style: 1, theme_style!: 1]
end
end
defmacro __before_compile__(env) do
declarations =
Module.get_attribute(env.module, :guppy_component_prop_declarations) |> Enum.reverse()
grouped =
Enum.group_by(declarations, fn {component, _name, _type, _opts} -> component end, fn {_,
name,
type,
opts} ->
%{
name: name,
type: type,
required: Keyword.get(opts, :required, false),
default: Keyword.get(opts, :default, :__guppy_no_default__)
}
end)
if grouped == %{} do
quote do
def __guppy_component_props__(_component_name), do: []
end
else
quote do
def __guppy_component_props__(component_name) do
Map.get(unquote(Macro.escape(grouped)), component_name, [])
end
end
end
end
defmacro sigil_GUI({:<<>>, _meta, [template]}, _modifiers) when is_binary(template) do
Guppy.Component.Compiler.compile!(template, __CALLER__)
end
defmacro prop(component, name, type, opts \\ []) do
quote bind_quoted: [component: component, name: name, type: type, opts: opts] do
@guppy_component_prop_declarations {component, name, type, opts}
end
end
def template_assigns!(binding) when is_list(binding) do
case fetch_template_assigns(binding) do
{:ok, assigns} ->
assigns
:error ->
case fetch_window_assigns(binding) do
{:ok, assigns} ->
assigns
:error ->
raise ArgumentError,
"~GUI @assigns require an assigns map or a Guppy.Window value named window in scope"
end
end
end
def fetch_assign!(assigns, key) when is_map(assigns) and is_atom(key) do
Map.fetch!(assigns, key)
end
def build_component_assigns(entries) do
Map.new(entries)
end
def validate_props!(module, component_name, assigns)
when is_atom(module) and is_atom(component_name) and is_map(assigns) do
schema = component_schema(module, component_name)
if schema == [] do
assigns
else
assigns
|> apply_prop_defaults(schema)
|> validate_required_props!(module, component_name, schema)
|> validate_unknown_props!(module, component_name, schema)
|> validate_prop_types!(module, component_name, schema)
end
end
def maybe_entry(_key, nil), do: nil
def maybe_entry(key, value), do: {key, value}
defp fetch_template_assigns(binding) do
case Keyword.fetch(binding, :assigns) do
{:ok, assigns} when is_map(assigns) -> {:ok, assigns}
{:ok, nil} -> :error
_ -> :error
end
end
defp fetch_window_assigns(binding) do
case Keyword.fetch(binding, :window) do
{:ok, %{__struct__: Guppy.Window, assigns: assigns}} when is_map(assigns) -> {:ok, assigns}
_ -> :error
end
end
defp component_schema(module, component_name) do
if function_exported?(module, :__guppy_component_props__, 1) do
module.__guppy_component_props__(component_name)
else
[]
end
end
defp apply_prop_defaults(assigns, schema) do
Enum.reduce(schema, assigns, fn %{name: name, default: default}, acc ->
case {Map.has_key?(acc, name), default} do
{true, _} -> acc
{false, :__guppy_no_default__} -> acc
{false, value} -> Map.put(acc, name, value)
end
end)
end
defp validate_required_props!(assigns, module, component_name, schema) do
missing =
schema
|> Enum.filter(fn %{required: required, name: name} ->
required and missing_prop?(assigns, name)
end)
|> Enum.map(& &1.name)
case missing do
[] ->
assigns
names ->
raise ArgumentError,
"missing required props for #{component_label(module, component_name)}: #{inspect(names)}"
end
end
defp validate_unknown_props!(assigns, module, component_name, schema) do
allowed = MapSet.new([:children | Enum.map(schema, & &1.name)])
unknown =
assigns
|> Map.keys()
|> Enum.reject(&MapSet.member?(allowed, &1))
case unknown do
[] ->
assigns
names ->
raise ArgumentError,
"unknown props for #{component_label(module, component_name)}: #{inspect(names)}"
end
end
defp validate_prop_types!(assigns, module, component_name, schema) do
Enum.each(schema, fn %{name: name, type: type} ->
value = Map.get(assigns, name)
if not is_nil(value) and not valid_prop_type?(value, type) do
raise ArgumentError,
"invalid value for prop #{inspect(name)} on #{component_label(module, component_name)}: expected #{inspect(type)}, got #{inspect(value)}"
end
end)
assigns
end
defp valid_prop_type?(_value, :any), do: true
defp valid_prop_type?(value, :string), do: is_binary(value)
defp valid_prop_type?(value, :integer), do: is_integer(value)
defp valid_prop_type?(value, :number), do: is_number(value)
defp valid_prop_type?(value, :boolean), do: is_boolean(value)
defp valid_prop_type?(value, :atom), do: is_atom(value)
defp valid_prop_type?(value, :list), do: is_list(value)
defp valid_prop_type?(value, :map), do: is_map(value)
defp valid_prop_type?(value, :keyword), do: Keyword.keyword?(value)
defp valid_prop_type?(value, :children), do: is_list(value)
defp valid_prop_type?(value, {:one_of, values}), do: value in values
defp valid_prop_type?(_value, _type), do: false
defp missing_prop?(assigns, name),
do: not Map.has_key?(assigns, name) or is_nil(Map.get(assigns, name))
defp component_label(module, component_name),
do: inspect(module) <> "." <> Atom.to_string(component_name) <> "/1"
def build_keyword(entries) do
entries
|> Enum.reject(&is_nil/1)
end
def build_events(entries) do
case Enum.reject(entries, &is_nil/1) do
[] -> nil
pairs -> Map.new(pairs)
end
end
def merge_styles(class_value, style_value) do
merge_precompiled_styles(normalize_class_value(class_value), style_value)
end
@doc false
def merge_precompiled_styles(class_styles, style_value) do
merged = class_styles ++ normalize_style_value(style_value)
if merged == [], do: nil, else: merged
end
@doc false
def merge_image_options(class_value, style_value, object_fit_value, grayscale_value) do
{class_styles, class_options} = image_class_to_style_and_options!(class_value)
merge_image_options_precompiled(
class_styles,
class_options,
style_value,
object_fit_value,
grayscale_value
)
end
@doc false
def merge_image_options_precompiled(
class_styles,
class_options,
style_value,
object_fit_value,
grayscale_value
) do
style = class_styles ++ normalize_style_value(style_value)
object_fit =
if is_nil(object_fit_value), do: Map.get(class_options, :object_fit), else: object_fit_value
grayscale =
if is_nil(grayscale_value), do: Map.get(class_options, :grayscale), else: grayscale_value
build_keyword([
maybe_entry(:style, if(style == [], do: nil, else: style)),
maybe_entry(:object_fit, object_fit),
maybe_entry(:grayscale, grayscale)
])
end
def flatten_children(children) do
children
|> List.flatten()
|> Enum.flat_map(&normalize_child/1)
end
def dynamic_child(value), do: normalize_child(value)
def normalize_child(nil), do: []
def normalize_child(false), do: []
def normalize_child(children) when is_list(children), do: flatten_children(children)
def normalize_child(%{} = node), do: [node]
def normalize_child(value), do: [Guppy.IR.text(to_text(value))]
def to_text(nil), do: ""
def to_text(false), do: ""
def to_text(value) when is_binary(value), do: value
def to_text(value), do: to_string(value)
def class_to_style!(value) do
value
|> class_tokens!()
|> Enum.flat_map(fn token -> List.wrap(class_token_to_style!(token)) end)
end
@doc false
def image_class_to_style_and_options!(value) do
value
|> class_tokens!()
|> Enum.reduce({[], %{}}, fn token, {styles, options} ->
case Guppy.Style.class_token_to_image_option(token) do
{:ok, {key, value}} -> {styles, Map.put(options, key, value)}
:error -> {Enum.reverse(List.wrap(class_token_to_style!(token)), styles), options}
end
end)
|> then(fn {styles, options} -> {Enum.reverse(styles), options} end)
end
defp class_tokens!(nil), do: []
defp class_tokens!(false), do: []
defp class_tokens!(value) when is_binary(value),
do: String.split(value, ~r/\s+/, trim: true)
defp class_tokens!(value) when is_list(value) do
Enum.flat_map(value, fn
nil ->
[]
false ->
[]
item when is_binary(item) ->
class_tokens!(item)
other ->
raise ArgumentError, "expected class list entries to be strings, got: #{inspect(other)}"
end)
end
defp class_tokens!(other) do
raise ArgumentError,
"expected class to be a string or list of strings, got: #{inspect(other)}"
end
defp normalize_class_value(value), do: class_to_style!(value)
defp normalize_style_value(nil), do: []
defp normalize_style_value(false), do: []
defp normalize_style_value(value) when is_binary(value) do
raise ArgumentError,
"expected style to be a canonical style list; use class for class tokens, got: #{inspect(value)}"
end
defp normalize_style_value(value) when is_list(value), do: value
defp normalize_style_value(other),
do:
raise(
ArgumentError,
"expected style to be nil, false, or a canonical style list; use class for class tokens, got: #{inspect(other)}"
)
@class_style_cache :guppy_class_style_cache
@doc false
# Dynamic class strings are re-tokenized on every render, so successful
# token parses are memoized. The token space is bounded by the class names
# an app actually uses, which keeps the table small.
def create_class_style_cache do
if :ets.whereis(@class_style_cache) == :undefined do
:ets.new(@class_style_cache, [:named_table, :public, read_concurrency: true])
end
:ok
end
defp class_token_to_style!(token) do
case lookup_cached_class_style(token) do
{:ok, style} ->
style
:miss ->
style = compute_class_token_style!(token)
cache_class_style(token, style)
style
end
end
# The cache table only exists once the :guppy application has started;
# templates rendered outside it just compute every time.
defp lookup_cached_class_style(token) do
case :ets.lookup(@class_style_cache, token) do
[{^token, style}] -> {:ok, style}
[] -> :miss
end
rescue
ArgumentError -> :miss
end
defp cache_class_style(token, style) do
:ets.insert(@class_style_cache, {token, style})
rescue
ArgumentError -> true
end
defp compute_class_token_style!(token) do
cond do
catalog_style = parse_catalog_style(token) ->
catalog_style
gradient_style = parse_linear_gradient_style(token) ->
gradient_style
hex_style = parse_hex_color_style(token) ->
hex_style
size_style = parse_size_style(token) ->
size_style
grid_style = parse_grid_style(token) ->
grid_style
true ->
raise ArgumentError, unsupported_class_token_message(token)
end
end
@named_color_names ~w(red green blue yellow black white gray)
defp unsupported_class_token_message(token) do
base = "unsupported Guppy class token: #{inspect(token)}."
color_hint =
if String.contains?(token, "-") and color_like_token?(token) do
" Named colors are limited to #{Enum.join(@named_color_names, "/")}; " <>
"use an arbitrary hex value like bg-[#1e293b] for other colors."
else
""
end
base <>
color_hint <>
" See Guppy.Style for the supported class surface (Guppy.Style.catalog/0 lists catalog tokens)."
end
defp color_like_token?(token) do
case String.split(token, "-", parts: 2) do
[prefix, _rest] -> prefix in ["bg", "text", "border", "decoration", "strikethrough"]
_ -> false
end
end
defp parse_catalog_style(token) do
case Guppy.Style.class_token_to_style(token) do
{:ok, style} -> style
:error -> nil
end
end
defp parse_hex_color_style(token) do
case Regex.run(~r/^(bg|text|border)-\[(#[0-9A-Fa-f]{6})\]$/, token, capture: :all_but_first) do
["bg", hex] -> {:bg_hex, hex}
["text", hex] -> {:text_color_hex, hex}
["border", hex] -> {:border_color_hex, hex}
_ -> nil
end
end
defp parse_linear_gradient_style(token) do
with [payload] <-
Regex.run(~r/^bg-linear-gradient-\[(.+)\]$/, token, capture: :all_but_first),
[angle, from, to] <- String.split(payload, ",", parts: 3),
{:ok, angle} <- Guppy.Style.parse_class_number(angle),
{:ok, from} <- Guppy.Style.parse_class_gradient_stop(from),
{:ok, to} <- Guppy.Style.parse_class_gradient_stop(to) do
{:bg_linear_gradient, [angle: angle, from: from, to: to]}
else
_ -> nil
end
end
defp parse_size_style(token) do
case Regex.run(~r/^(w|h)-\[([0-9]+(?:\.[0-9]+)?)(px|rem)\]$/, token, capture: :all_but_first) do
["w", number, "px"] -> {:w_px, Guppy.Style.parse_class_number!(number)}
["w", number, "rem"] -> {:w_rem, Guppy.Style.parse_class_number!(number)}
["h", number, "px"] -> {:h_px, Guppy.Style.parse_class_number!(number)}
["h", number, "rem"] -> {:h_rem, Guppy.Style.parse_class_number!(number)}
_ -> nil
end
end
defp parse_grid_style(token) do
case Regex.run(~r/^(grid-cols|grid-rows|col-span|row-span)-\[([0-9]+)\]$/, token,
capture: :all_but_first
) do
["grid-cols", integer] -> {:grid_cols, String.to_integer(integer)}
["grid-rows", integer] -> {:grid_rows, String.to_integer(integer)}
["col-span", integer] -> {:col_span, String.to_integer(integer)}
["row-span", integer] -> {:row_span, String.to_integer(integer)}
_ -> nil
end
end
end