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 </.dialup_action> 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</.dialup_action> 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""" <a id={@id} class={@class} href={@navigate_to} ws-href={@navigate_to} data-dialup-id={@action_name} data-dialup-kind="navigate" data-dialup-desc={@action_desc} aria-disabled={to_string(!@available)} > {render_slot(@inner_block)} </a> """ 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""" <button id={@id} type={@type} class={@class} ws-event={@action_name} data-dialup-id={@action_name} data-dialup-kind="command" data-dialup-params={@encoded_params} data-dialup-confirm={@confirm_name} data-dialup-desc={@action_desc} disabled={!@available} aria-disabled={to_string(!@available)} > {render_slot(@inner_block)} </button> """ 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""" <button id={@id} type={@type} class={@class} ws-event={@action_name} data-dialup-id={@action_name} data-dialup-kind="set" data-dialup-desc={@action_desc} disabled={!@available} aria-disabled={to_string(!@available)} > {render_slot(@inner_block)} </button> """ 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""" <button id={@id} type={@type} class={@class} ws-event={@action_name} data-dialup-id={@action_name} data-dialup-kind="action" data-dialup-params={@encoded_params} data-dialup-confirm={@confirm_name} data-dialup-desc={@action_desc} disabled={!@available} aria-disabled={to_string(!@available)} > {render_slot(@inner_block)} </button> """ 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""" <section id={@id} class={@class} role={@role} data-dialup-id={@region_name} data-dialup-kind="region" data-dialup-desc={@desc} > {render_slot(@inner_block)} </section> """ 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(<<char::utf8, rest::binary>>, depth, quote, acc) do cond do quote && char == quote -> take_opening(rest, depth, nil, [<<char::utf8>> | acc]) quote -> take_opening(rest, depth, quote, [<<char::utf8>> | acc]) char in [?", ?'] -> take_opening(rest, depth, char, [<<char::utf8>> | 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, [<<char::utf8>> | 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(<<char::utf8, rest::binary>>, depth, quote, acc) do cond do quote && char == quote -> take_expression(rest, depth, nil, [<<char::utf8>> | acc]) quote -> take_expression(rest, depth, quote, [<<char::utf8>> | acc]) char in [?", ?'] -> take_expression(rest, depth, char, [<<char::utf8>> | 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, [<<char::utf8>> | acc]) end end end