defmodule Dialup.Page do
import Phoenix.Component, only: [render_slot: 1, sigil_H: 2]
@moduledoc """
The behaviour and macro for Dialup page modules.
A page module handles a single route. Place it at the appropriate path under your
`app_dir` and it will be routed automatically:
# lib/app/page.ex → /
# lib/app/blog/[slug]/page.ex → /blog/:slug
## Usage
defmodule MyApp.App.Page do
use Dialup.Page
def mount(_params, assigns) do
{:ok, Map.put(assigns, :count, 0)}
end
def handle_event("increment", _, assigns) do
{:update, Map.update!(assigns, :count, &(&1 + 1))}
end
def render(assigns) do
~H\"\"\"
Count: {@count}
\"\"\"
end
end
## Callbacks
- `mount/2` — called on every page navigation. Receives URL params and the current assigns
(which already include session data set by layouts). Return `{:ok, new_assigns}` or
`{:redirect, path}` / `{:redirect, path, assigns}` to navigate before render.
Defining `mount/1` (assigns only) is also accepted as a convenience.
- `render/1` — renders the page HTML using HEEx.
- `handle_event/3` — called when a `ws-event`, `ws-submit`, or `ws-change` fires.
- `handle_info/2` — called for Erlang process messages (e.g. PubSub, timers).
- `page_title/1` — optional; return a string to set ``. Return `nil` to use the
application default.
- `agent_state/1` — returns the allowlisted state projection visible to an attached agent.
- `agent_message/1` — explains the page concept, operating flow, and safety constraints
to an agent that has no source-code context.
- `agent_grant/1` — defines capabilities, projections, expiry, and version requirements
for HTTP MCP session tokens. The default grant is read-only (`[:read_scene]`); override
explicitly for mutating or built-in tools such as `lock_ui`.
## Human and agent projections
Use `<.dialup_action>` instead of a plain event button when the same operation should be
available to an attached agent. Use `<.dialup_region>` for stable domain areas that need
a semantic name, structured data, or action relationships.
`<.dialup_action>` is the single boundary for what an agent can do: raw `ws-event`,
`ws-submit`, `ws-change`, and `ws-href` elements are never auto-exposed as tools. To make a
navigation link agent-operable, declare it with `<.dialup_action navigate="/path">`; the same
declaration renders the human link and generates the navigation tool.
## Action modes
Each `<.dialup_action>` uses exactly one mode:
| Mode | Attribute | Routing |
|------|-----------|---------|
| `command` | `command={{Context, :cmd}}` | Build Commanded command → `Context.dispatch/1` → remount |
| `set` | `set={%{key: value}}` | Merge rendered map into assigns (inline HEEx only) |
| `navigate` | `navigate="/path"` | Navigate to declared path |
| `action` | `name={:event}` | Legacy `handle_event/3` |
See `guides/agent-native-app-development.md` for Commanded integration and availability derivation.
## Return values for `handle_event/3` and `handle_info/2`
| Return value | Effect |
|---|---|
| `{:noreply, assigns}` | Update state, no re-render |
| `{:update, assigns}` | Re-render the full page |
| `{:patch, id, html, assigns}` | Replace only the element with the given `id` |
| `{:redirect, path, assigns}` | Navigate to another page (session is preserved) |
| `{:push_event, name, payload, assigns}` | Call a JS hook function and re-render |
## Module attributes
- `@layout false` — disable all layout wrapping (useful for login/fullscreen pages).
- `@static true` — serve the page without establishing a WebSocket connection.
## Colocation CSS
Place a `page.css` file in the same directory as `page.ex`. It is automatically scoped
to the page at compile time (no build tool required).
## Helpers
`use Dialup.Page` imports `overwrite/2`, `set_default/2`, `subscribe/2`,
`declare_action/1`, `declare_region/1`, `dialup_action/1`, and `dialup_region/1`.
"""
@callback render(assigns :: map()) :: Phoenix.LiveView.Rendered.t()
# params: URLパラメータ
# assigns: session の内容(current_user 等を読み取り可能)
# 返り値の map が page assigns になる(session キーは自動的に除外される)
@callback mount(params :: map(), assigns :: map()) ::
{:ok, map()} | {:redirect, path :: binary()} | {:redirect, path :: binary(), map()}
@callback mount(assigns :: map()) ::
{:ok, map()} | {:redirect, path :: binary()} | {:redirect, path :: binary(), map()}
# 再描画なし(状態のみ更新)
@callback handle_event(event :: binary(), value :: any(), assigns :: map()) ::
{:noreply, map()}
| {:update, map()}
| {:patch, target :: binary(), rendered :: any(), map()}
| {:redirect, path :: binary(), map()}
| {:push_event, event_name :: binary(), payload :: map(), map()}
@callback handle_info(msg :: any(), assigns :: map()) ::
{:noreply, map()}
| {:update, map()}
| {:patch, target :: binary(), rendered :: any(), map()}
| {:redirect, path :: binary(), map()}
| {:push_event, event_name :: binary(), payload :: map(), map()}
# ページタイトルを動的に設定するためのオプショナルコールバック
@callback page_title(assigns :: map()) :: binary() | nil
@callback agent_state(assigns :: map()) :: map()
@callback agent_grant(assigns :: map()) :: map() | keyword()
@callback agent_message(assigns :: map()) :: binary() | map()
@optional_callbacks mount: 1,
handle_info: 2,
page_title: 1,
agent_state: 1,
agent_grant: 1,
agent_message: 1
@doc """
Declares an action that is available to both the browser UI and an attached agent.
declare_action name: :increment,
desc: "Increment the counter",
params: %{amount: {:integer, default: 1}}
"""
defmacro declare_action(opts) do
{opts, _binding} = Code.eval_quoted(opts, [], __CALLER__)
opts = Map.new(opts)
opts =
case Map.pop(opts, :available) do
{nil, opts} ->
opts
{ast, opts} ->
Map.put(opts, :available_expr, Macro.to_string(ast))
end
quote do
@dialup_actions unquote(Macro.escape(opts))
end
end
@doc """
Declares a semantic region that an attached agent can reference.
"""
defmacro declare_region(opts) do
{opts, _binding} = Code.eval_quoted(opts, [], __CALLER__)
quote do
@dialup_regions unquote(Macro.escape(Map.new(opts)))
end
end
@doc """
Renders an action button and serializes extra component attributes as event parameters.
<.dialup_action name={:add_item} available={@status == :draft} sku="SKU-1" qty="1">
Add item
Pass `navigate` to render a navigation link instead of an event button. The same
declaration becomes a navigation tool for an attached agent, so the agent can move
between pages exactly where a human can click:
<.dialup_action navigate="/docs/concepts">Concepts
Navigation actions take no parameters; the destination is fixed at the declaration
site. When `name` is omitted it is derived from the path (e.g. `/docs/concepts`
becomes `:navigate_docs__concepts`).
"""
def dialup_action(%{navigate: navigate} = assigns) when is_binary(navigate) do
name =
case Map.get(assigns, :name) do
nil -> navigate_action_name(navigate)
explicit -> explicit
end
|> to_string()
assigns =
assigns
|> Phoenix.Component.assign(:action_name, name)
|> Phoenix.Component.assign(:navigate_to, navigate)
|> Phoenix.Component.assign(:available, Map.get(assigns, :available, true))
|> Phoenix.Component.assign(:action_desc, Map.get(assigns, :desc))
|> Phoenix.Component.assign_new(:class, fn -> nil end)
|> Phoenix.Component.assign_new(:id, fn -> nil end)
~H"""
{render_slot(@inner_block)}
"""
end
def dialup_action(%{command: command} = assigns) when not is_nil(command) do
{context_module, command_name} = normalize_command!(command)
name = assigns |> Map.get(:name, command_name) |> to_string()
available = Map.get(assigns, :available, true)
bind = Map.get(assigns, :bind, %{})
Dialup.BindActions.record(name, bind)
params =
assigns
|> Map.drop(action_reserved_keys())
|> Jason.encode!()
assigns =
assigns
|> Phoenix.Component.assign(:action_name, name)
|> Phoenix.Component.assign(:available, available)
|> Phoenix.Component.assign(
:confirm_name,
assigns[:confirm] && to_string(assigns[:confirm])
)
|> Phoenix.Component.assign(:action_desc, Map.get(assigns, :desc))
|> Phoenix.Component.assign(:encoded_params, params)
|> Phoenix.Component.assign_new(:class, fn -> nil end)
|> Phoenix.Component.assign_new(:id, fn -> nil end)
|> Phoenix.Component.assign_new(:type, fn -> "button" end)
_ = context_module
~H"""
"""
end
def dialup_action(%{set: set} = assigns) when is_map(set) do
name = assigns |> Map.fetch!(:name) |> to_string()
available = Map.get(assigns, :available, true)
Dialup.SetActions.record(name, set)
assigns =
assigns
|> Phoenix.Component.assign(:action_name, name)
|> Phoenix.Component.assign(:available, available)
|> Phoenix.Component.assign(:action_desc, Map.get(assigns, :desc))
|> Phoenix.Component.assign_new(:class, fn -> nil end)
|> Phoenix.Component.assign_new(:id, fn -> nil end)
|> Phoenix.Component.assign_new(:type, fn -> "button" end)
~H"""
"""
end
def dialup_action(assigns) do
name = assigns |> Map.fetch!(:name) |> to_string()
available = Map.get(assigns, :available, true)
confirm = Map.get(assigns, :confirm)
params =
assigns
|> Map.drop(action_reserved_keys())
|> Jason.encode!()
assigns =
assigns
|> Phoenix.Component.assign(:action_name, name)
|> Phoenix.Component.assign(:available, available)
|> Phoenix.Component.assign(:confirm_name, confirm && to_string(confirm))
|> Phoenix.Component.assign(:action_desc, Map.get(assigns, :desc))
|> Phoenix.Component.assign(:encoded_params, params)
|> Phoenix.Component.assign_new(:class, fn -> nil end)
|> Phoenix.Component.assign_new(:id, fn -> nil end)
|> Phoenix.Component.assign_new(:type, fn -> "button" end)
~H"""
"""
end
@doc """
Derives the canonical navigation action name for an app path.
`/docs/concepts` becomes `:navigate_docs__concepts` and `/` becomes `:navigate_root`.
Path segments are joined with `__` so `/foo-bar` and `/foo/bar` stay distinct.
"""
def navigate_action_name(path) when is_binary(path) do
trimmed = String.trim(path, "/")
slug =
if trimmed == "" do
"root"
else
trimmed
|> String.split("/")
|> Enum.map(&segment_slug/1)
|> Enum.join("__")
end
String.to_atom("navigate_" <> slug)
end
defp normalize_command!({context_module, command_name})
when is_atom(context_module) and is_atom(command_name),
do: {context_module, command_name}
defp normalize_command!({context_module, command_name})
when is_atom(context_module) and is_binary(command_name),
do: {context_module, String.to_atom(command_name)}
defp normalize_command!(other) do
raise ArgumentError,
"command must be {ContextModule, :command_name}, got: #{inspect(other)}"
end
defp action_reserved_keys do
[
:__changed__,
:inner_block,
:name,
:available,
:confirm,
:desc,
:params,
:agent_only,
:risk,
:effects,
:reversible,
:idempotent,
:examples,
:success,
:class,
:id,
:type,
:navigate,
:command,
:bind,
:errors,
:set
]
end
@doc false
def action_mode(action) do
cond do
Map.has_key?(action, :navigate) -> :navigate
Map.has_key?(action, :command) -> :command
Map.has_key?(action, :set) -> :set
true -> :action
end
end
@doc false
def command_action_name({_context, command_name}), do: command_name
defp segment_slug(segment) do
segment
|> String.replace(~r/[^a-zA-Z0-9]+/, "_")
|> String.trim("_")
end
@doc """
Wraps content in a semantic region that agents can read through `read_scene`.
"""
def dialup_region(assigns) do
assigns =
assigns
|> Phoenix.Component.assign(:region_name, assigns.name |> to_string())
|> Phoenix.Component.assign_new(:role, fn -> nil end)
|> Phoenix.Component.assign_new(:desc, fn -> nil end)
|> Phoenix.Component.assign_new(:class, fn -> nil end)
|> Phoenix.Component.assign_new(:id, fn -> nil end)
~H"""
{render_slot(@inner_block)}
"""
end
@doc """
Merges `overwrite_map` into `assigns`, replacing any existing keys.
assigns |> overwrite(%{user: user, loaded: true})
"""
def overwrite(assigns, overwrite) when is_map(assigns) and is_map(overwrite) do
Map.merge(assigns, overwrite)
end
@doc """
Merges `defaults` into `assigns`, keeping existing values for keys that are already set.
assigns |> set_default(%{count: 0, page: 1})
"""
def set_default(assigns, defaults) when is_map(assigns) and is_map(defaults) do
Map.merge(defaults, assigns)
end
@doc """
Subscribes to a `Phoenix.PubSub` topic and registers it for automatic unsubscription
on page navigation.
Call this inside `mount/2` to ensure the subscription is cleaned up when the user
navigates away.
def mount(_params, assigns) do
subscribe(MyApp.PubSub, "room:lobby")
{:ok, %{messages: []}}
end
"""
def subscribe(pubsub, topic) do
Phoenix.PubSub.subscribe(pubsub, topic)
subs = Process.get(:dialup_subscriptions, [])
Process.put(:dialup_subscriptions, [{pubsub, topic} | subs])
:ok
end
defmacro __using__(_opts) do
quote do
@behaviour Dialup.Page
import Phoenix.Component
import Phoenix.HTML, only: [raw: 1]
# ローカルでの使用も可能に(import)
import Dialup.Page,
only: [
overwrite: 2,
set_default: 2,
subscribe: 2,
declare_action: 1,
declare_region: 1,
dialup_action: 1,
dialup_region: 1
]
Module.register_attribute(__MODULE__, :dialup_actions, accumulate: true)
Module.register_attribute(__MODULE__, :dialup_regions, accumulate: true)
# デフォルト実装
def handle_event(_event, _value, assigns), do: {:noreply, assigns}
def handle_info(_msg, assigns), do: {:noreply, assigns}
def page_title(_assigns), do: nil
def agent_state(_assigns), do: %{}
def agent_message(_assigns) do
%{
purpose: "This Dialup page exposes MCP tools generated from its UI declarations.",
instructions: [
"Read the semantic scene before acting.",
"Use the returned state version for every mutating action.",
"Actions marked confirm=human return an isError tool result over HTTP MCP."
]
}
end
def agent_grant(assigns) do
Dialup.Auth.Grants.for_user(assigns)
end
defoverridable handle_event: 3,
handle_info: 2,
page_title: 1,
agent_state: 1,
agent_grant: 1,
agent_message: 1
@before_compile Dialup.Page
end
end
defmacro __before_compile__(env) do
template_path = env.file |> Path.rootname(".ex") |> Kernel.<>(".html.heex")
has_render = Module.defines?(env.module, {:render, 1})
has_template = File.exists?(template_path)
# mount/1 と mount/2 の定義をチェック
has_mount_1 = Module.defines?(env.module, {:mount, 1})
has_mount_2 = Module.defines?(env.module, {:mount, 2})
# mount関数の生成
mount_quote =
cond do
has_mount_2 ->
quote do
# mount/2 が定義済み
end
# mount/2 ラッパーを生成
has_mount_1 and not has_mount_2 ->
quote do
def mount(_params, assigns) do
mount(assigns)
end
end
# デフォルトの mount/2 を生成
true ->
quote do
def mount(_params, assigns), do: {:ok, assigns}
end
end
# テンプレート用のrender関数
render_quote =
cond do
has_render ->
quote do
# render/1 が定義済み
end
has_template ->
source = File.read!(template_path)
compiled =
EEx.compile_string(source,
engine: Phoenix.LiveView.TagEngine,
line: 1,
file: template_path,
caller: __CALLER__,
source: source,
tag_handler: Phoenix.LiveView.HTMLEngine
)
quote do
@external_resource unquote(template_path)
def render(assigns) do
_ = assigns
unquote(compiled)
end
end
true ->
raise CompileError,
file: env.file,
line: env.line,
description:
"#{inspect(env.module)} must define render/1 or provide #{Path.basename(template_path)}"
end
css_path = env.file |> Path.rootname(".ex") |> Kernel.<>(".css")
css_quote =
if File.exists?(css_path) do
css_content = File.read!(css_path)
scope_class =
"d-" <>
(env.module
|> Atom.to_string()
|> :erlang.md5()
|> Base.encode16(case: :lower)
|> binary_part(0, 7))
scoped_css = ".#{scope_class} {\n#{css_content}\n}"
quote do
@external_resource unquote(css_path)
def __css__, do: unquote(scoped_css)
def __css_scope__, do: unquote(scope_class)
end
else
quote do
def __css__, do: nil
def __css_scope__, do: nil
end
end
use_layout = Module.get_attribute(env.module, :layout, true)
actions = actions_from_env(env)
declared_regions = env.module |> Module.get_attribute(:dialup_regions, []) |> Enum.reverse()
regions = merge_regions!(env, declared_regions, extract_inline_regions!(env))
validate_semantic_declarations!(env, actions, regions)
validate_action_modes!(env, actions)
available_quote = available_codegen!(env, actions)
layout_quote =
quote do
def __layout__, do: unquote(use_layout)
end
quote do
unquote(mount_quote)
unquote(render_quote)
unquote(css_quote)
unquote(layout_quote)
unquote(available_quote)
def __dialup_actions__, do: unquote(Macro.escape(actions))
def __dialup_regions__, do: unquote(Macro.escape(regions))
end
end
@doc false
def actions_from_env(env) do
declared = env.module |> Module.get_attribute(:dialup_actions, []) |> Enum.reverse()
merge_actions!(env, declared, extract_inline_actions!(env))
end
@doc false
def validate_navigation_actions!(env, actions) do
validate_unique!(env, actions, "action")
Enum.each(actions, fn action ->
for key <- [:name, :desc, :params], not Map.has_key?(action, key) do
raise CompileError,
file: env.file,
line: env.line,
description: "Dialup action is missing required #{inspect(key)} metadata"
end
unless Map.has_key?(action, :navigate) do
raise CompileError,
file: env.file,
line: env.line,
description:
"layouts only support navigation actions; declare #{inspect(action.name)} with " <>
"<.dialup_action navigate=\"/path\"> (event-handling actions belong on a page)"
end
end)
end
defp validate_semantic_declarations!(env, actions, regions) do
validate_unique!(env, actions, "action")
validate_unique!(env, regions, "region")
validate_navigate_paths!(env, actions)
Enum.each(actions, fn action ->
mode = action_mode(action)
cond do
mode == :navigate ->
for key <- [:name, :desc, :params, :navigate], not Map.has_key?(action, key) do
raise CompileError,
file: env.file,
line: env.line,
description: "Dialup navigation action is missing required #{inspect(key)} metadata"
end
mode == :command ->
for key <- [:name, :desc, :params, :command], not Map.has_key?(action, key) do
raise CompileError,
file: env.file,
line: env.line,
description: "Dialup command action is missing required #{inspect(key)} metadata"
end
mode == :set ->
if Map.get(action, :set) != :runtime do
raise CompileError,
file: env.file,
line: env.line,
description:
"Dialup set actions must be declared inline in HEEx with set={...}, not via declare_action/1"
end
for key <- [:name, :desc, :set], not Map.has_key?(action, key) do
raise CompileError,
file: env.file,
line: env.line,
description: "Dialup set action is missing required #{inspect(key)} metadata"
end
unless Map.has_key?(action, :params) do
raise CompileError,
file: env.file,
line: env.line,
description: "Dialup set action #{inspect(action.name)} must declare params: %{}"
end
true ->
for key <- [:name, :desc, :params], not Map.has_key?(action, key) do
raise CompileError,
file: env.file,
line: env.line,
description: "Dialup action is missing required #{inspect(key)} metadata"
end
end
end)
Enum.each(regions, fn region ->
for key <- [:name, :role, :desc], not Map.has_key?(region, key) do
raise CompileError,
file: env.file,
line: env.line,
description: "Dialup region is missing required #{inspect(key)} metadata"
end
end)
validate_rendered_actions!(env, actions)
end
defp validate_unique!(env, declarations, kind) do
names = Enum.map(declarations, &Map.get(&1, :name))
duplicates = names -- Enum.uniq(names)
if duplicates != [] do
raise CompileError,
file: env.file,
line: env.line,
description: "duplicate Dialup #{kind} declarations: #{inspect(Enum.uniq(duplicates))}"
end
end
defp validate_navigate_paths!(env, actions) do
navigates = Enum.filter(actions, &Map.has_key?(&1, :navigate))
collisions =
navigates
|> Enum.group_by(& &1.name)
|> Enum.flat_map(fn {name, group} ->
paths = group |> Enum.map(& &1.navigate) |> Enum.uniq()
if length(paths) > 1 do
[{name, paths}]
else
[]
end
end)
if collisions != [] do
{name, paths} = hd(collisions)
raise CompileError,
file: env.file,
line: env.line,
description:
"conflicting navigate paths for action #{inspect(name)}: #{inspect(paths)}. " <>
"Use distinct paths or explicit name={...} overrides."
end
end
defp validate_rendered_actions!(env, actions) do
rendered =
env
|> semantic_source()
|> component_openings("<.dialup_action")
|> Enum.map(&component_action_name!(&1, env))
|> Enum.map(&to_string/1)
|> MapSet.new()
declared = actions |> Enum.map(&to_string(&1.name)) |> MapSet.new()
undeclared = MapSet.difference(rendered, declared) |> MapSet.to_list()
if undeclared != [] do
raise CompileError,
file: env.file,
line: env.line,
description: "rendered Dialup actions are not declared: #{inspect(undeclared)}"
end
missing =
actions
|> Enum.reject(&Map.get(&1, :agent_only, false))
|> Enum.map(&to_string(&1.name))
|> Enum.reject(&MapSet.member?(rendered, &1))
if missing != [] do
IO.warn(
"Dialup actions are declared but not rendered; add agent_only: true if intentional: #{inspect(missing)}",
Macro.Env.stacktrace(env)
)
end
end
defp extract_inline_actions!(env) do
env
|> semantic_source()
|> component_openings("<.dialup_action")
|> Enum.flat_map(fn attrs ->
validate_inline_modes!(env, attrs)
navigate = component_attr(attrs, "navigate", env)
command = component_attr(attrs, "command", env)
set = component_attr(attrs, "set", env)
name = component_action_name!(attrs, env, command)
desc = component_attr(attrs, "desc", env)
params = component_attr(attrs, "params", env)
available_expr = component_attr_ast(attrs, "available")
common =
%{
confirm: component_attr(attrs, "confirm", env),
agent_only: component_attr(attrs, "agent_only", env) || false,
risk: component_attr(attrs, "risk", env),
effects: component_attr(attrs, "effects", env),
reversible: component_attr(attrs, "reversible", env),
idempotent: component_attr(attrs, "idempotent", env),
examples: component_attr(attrs, "examples", env),
success: component_attr(attrs, "success", env),
available_expr: available_expr
}
|> Enum.reject(fn {_key, value} -> is_nil(value) end)
|> Map.new()
cond do
not is_nil(navigate) ->
[Map.merge(navigate_declaration(attrs, env, name, navigate, desc), common)]
not is_nil(command) ->
if is_nil(desc) and is_nil(params) do
[]
else
if is_nil(desc) or is_nil(params) do
raise CompileError,
file: env.file,
line: env.line,
description:
"inline Dialup command action #{inspect(name)} must provide both desc and params"
end
[
Map.merge(
%{
name: name,
desc: desc,
params: params,
command: command,
bind: component_attr(attrs, "bind", env) || %{},
errors: component_attr(attrs, "errors", env) || %{}
},
common
)
]
end
not is_nil(set) ->
if is_nil(desc) do
raise CompileError,
file: env.file,
line: env.line,
description: "inline Dialup set action #{inspect(name)} must provide desc"
end
[
Map.merge(
%{
name: name,
desc: desc,
params: params || %{},
set: :runtime
},
common
)
]
is_nil(desc) and is_nil(params) ->
[]
is_nil(desc) or is_nil(params) ->
raise CompileError,
file: env.file,
line: env.line,
description: "inline Dialup action #{inspect(name)} must provide both desc and params"
true ->
[Map.merge(%{name: name, desc: desc, params: params}, common)]
end
end)
end
defp validate_inline_modes!(env, attrs) do
modes =
[
{"navigate", component_attr(attrs, "navigate", env)},
{"command", component_attr(attrs, "command", env)},
{"set", component_attr(attrs, "set", env)}
]
|> Enum.reject(fn {_name, value} -> is_nil(value) end)
if length(modes) > 1 do
names = Enum.map(modes, &elem(&1, 0))
raise CompileError,
file: env.file,
line: env.line,
description:
"Dialup action modes are exclusive; found #{inspect(names)} on one <.dialup_action>"
end
end
defp validate_action_modes!(env, actions) do
Enum.each(actions, fn action ->
modes =
[:navigate, :command, :set]
|> Enum.filter(&Map.has_key?(action, &1))
if length(modes) > 1 do
raise CompileError,
file: env.file,
line: env.line,
description:
"Dialup action #{inspect(action.name)} declares multiple modes: #{inspect(modes)}"
end
end)
end
@doc false
def eval_available!(expr, assigns) when is_binary(expr) and is_map(assigns) do
normalized =
Regex.replace(~r/@(\w+)/, expr, fn _, key ->
"Map.get(assigns, :#{key})"
end)
{result, _} = Code.eval_string(normalized, [assigns: assigns], [])
result
end
defp available_codegen!(env, actions) do
available_actions = Enum.filter(actions, &Map.has_key?(&1, :available_expr))
user_defined = Module.defines?(env.module, {:__available__, 2})
if available_actions != [] and user_defined do
raise CompileError,
file: env.file,
line: env.line,
description:
"#{inspect(env.module)} defines __available__/2 manually, but available={...} " <>
"expressions also exist on <.dialup_action>. Remove one or the other."
end
if available_actions == [] do
quote do
end
else
clauses =
Enum.map(available_actions, fn action ->
quote do
def __available__(unquote(action.name), assigns) do
Dialup.Page.eval_available!(unquote(action.available_expr), assigns)
end
end
end)
fallback_names =
actions
|> Enum.reject(&Map.has_key?(&1, :available_expr))
|> Enum.map(& &1.name)
fallback_clauses =
Enum.map(fallback_names, fn name ->
quote do
def __available__(unquote(name), _assigns), do: true
end
end)
catch_all =
quote do
def __available__(_action, _assigns), do: true
end
quote do
unquote_splicing(clauses)
unquote_splicing(fallback_clauses)
unquote(catch_all)
end
end
end
defp navigate_declaration(attrs, env, name, navigate, desc) do
%{
name: name,
navigate: navigate,
desc: desc || "Open #{navigate}",
params: %{},
confirm: component_attr(attrs, "confirm", env),
risk: component_attr(attrs, "risk", env),
effects: component_attr(attrs, "effects", env),
available_expr: component_attr_ast(attrs, "available")
}
|> Enum.reject(fn {_key, value} -> is_nil(value) end)
|> Map.new()
end
defp extract_inline_regions!(env) do
env
|> semantic_source()
|> component_openings("<.dialup_region")
|> Enum.map(fn attrs ->
name = required_component_name!(attrs, env)
role = component_attr(attrs, "role", env)
desc = component_attr(attrs, "desc", env)
if is_nil(role) or is_nil(desc) do
raise CompileError,
file: env.file,
line: env.line,
description:
"inline Dialup region #{inspect(name)} must provide compile-time role and desc"
end
%{
name: name,
role: role,
desc: desc,
data: component_attr(attrs, "data", env),
actions: component_attr(attrs, "actions", env),
parent: component_attr(attrs, "parent", env)
}
|> Enum.reject(fn {_key, value} -> is_nil(value) end)
|> Map.new()
end)
end
defp merge_actions!(env, declared, inline) do
declared_names = MapSet.new(declared, & &1.name)
conflicts = inline |> Enum.map(& &1.name) |> Enum.filter(&MapSet.member?(declared_names, &1))
if conflicts != [] do
raise CompileError,
file: env.file,
line: env.line,
description:
"Dialup action metadata is defined both inline and with declare_action: #{inspect(Enum.uniq(conflicts))}"
end
declared ++ inline
end
defp merge_regions!(env, declared, inline) do
Enum.reduce(inline, declared, fn region, acc ->
case Enum.find_index(acc, &(&1.name == region.name)) do
nil ->
acc ++ [region]
index ->
existing = Enum.at(acc, index)
if existing.role != region.role or existing.desc != region.desc do
raise CompileError,
file: env.file,
line: env.line,
description: "conflicting Dialup region metadata for #{inspect(region.name)}"
end
List.replace_at(acc, index, Map.merge(region, existing))
end
end)
end
defp semantic_source(env) do
template_path = env.file |> Path.rootname(".ex") |> Kernel.<>(".html.heex")
File.read!(env.file) <>
if(File.exists?(template_path), do: File.read!(template_path), else: "")
end
defp component_openings(source, marker), do: component_openings(source, marker, [])
defp component_openings(source, marker, acc) do
case :binary.match(source, marker) do
:nomatch ->
Enum.reverse(acc)
{index, length} ->
rest = binary_part(source, index + length, byte_size(source) - index - length)
{attrs, remainder} = take_opening(rest, 0, nil, [])
component_openings(remainder, marker, [attrs | acc])
end
end
defp take_opening(<<>>, _depth, _quote, acc),
do: {acc |> Enum.reverse() |> IO.iodata_to_binary(), ""}
defp take_opening(<>, depth, quote, acc) do
cond do
quote && char == quote ->
take_opening(rest, depth, nil, [<> | acc])
quote ->
take_opening(rest, depth, quote, [<> | acc])
char in [?", ?'] ->
take_opening(rest, depth, char, [<> | acc])
char == ?{ ->
take_opening(rest, depth + 1, nil, ["{" | acc])
char == ?} ->
take_opening(rest, max(depth - 1, 0), nil, ["}" | acc])
char == ?> and depth == 0 ->
{acc |> Enum.reverse() |> IO.iodata_to_binary(), rest}
true ->
take_opening(rest, depth, nil, [<> | acc])
end
end
defp required_component_name!(attrs, env) do
case component_attr(attrs, "name", env) do
nil ->
raise CompileError,
file: env.file,
line: env.line,
description: "Dialup action name must be a compile-time constant"
name ->
name
end
end
# Like required_component_name!/2, but derives the name from navigate or command
# targets when no explicit name is given.
defp component_action_name!(attrs, env, command \\ nil) do
command = command || component_attr(attrs, "command", env)
case component_attr(attrs, "name", env) do
nil ->
cond do
not is_nil(command) ->
command_action_name(command)
true ->
case component_attr(attrs, "navigate", env) do
path when is_binary(path) ->
navigate_action_name(path)
nil ->
case component_attr(attrs, "set", env) do
nil ->
raise CompileError,
file: env.file,
line: env.line,
description:
"Dialup action must provide a compile-time name, navigate target, or command"
_ ->
raise CompileError,
file: env.file,
line: env.line,
description: "Dialup set action must provide an explicit name={...}"
end
_other ->
raise CompileError,
file: env.file,
line: env.line,
description: "Dialup action navigate target must be a compile-time string"
end
end
name ->
name
end
end
defp component_attr_ast(attrs, key) do
braced = ~r/\b#{Regex.escape(key)}\s*=\s*\{/
case Regex.run(braced, attrs, return: :index) do
[{start, length}] ->
expression_and_rest =
binary_part(attrs, start + length, byte_size(attrs) - start - length)
{expression, _rest} = take_expression(expression_and_rest, 1, nil, [])
String.trim(expression)
nil ->
nil
end
end
defp component_attr(attrs, key, env) do
braced = ~r/\b#{Regex.escape(key)}\s*=\s*\{/
case Regex.run(braced, attrs, return: :index) do
[{start, length}] ->
expression_and_rest =
binary_part(attrs, start + length, byte_size(attrs) - start - length)
{expression, _rest} = take_expression(expression_and_rest, 1, nil, [])
{value, _binding} = Code.eval_string(expression, [], env)
value
nil ->
quoted = ~r/\b#{Regex.escape(key)}\s*=\s*"([^"]*)"/
case Regex.run(quoted, attrs, capture: :all_but_first) do
[value] -> value
nil -> nil
end
end
end
defp take_expression(<<>>, _depth, _quote, acc),
do: {acc |> Enum.reverse() |> IO.iodata_to_binary(), ""}
defp take_expression(<>, depth, quote, acc) do
cond do
quote && char == quote ->
take_expression(rest, depth, nil, [<> | acc])
quote ->
take_expression(rest, depth, quote, [<> | acc])
char in [?", ?'] ->
take_expression(rest, depth, char, [<> | acc])
char == ?{ ->
take_expression(rest, depth + 1, nil, ["{" | acc])
char == ?} and depth == 1 ->
{acc |> Enum.reverse() |> IO.iodata_to_binary(), rest}
char == ?} ->
take_expression(rest, depth - 1, nil, ["}" | acc])
true ->
take_expression(rest, depth, nil, [<> | acc])
end
end
end