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}`. 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. ## 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. Ordinary visible DOM elements remain available for human spatial references, but their generated selectors are less stable than region IDs. See `guides/agent-native-app-development.md` for the complete implementation workflow. ## 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()} @callback mount(assigns :: map()) :: {:ok, 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__) quote do @dialup_actions unquote(Macro.escape(Map.new(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(assigns) do name = assigns |> Map.fetch!(:name) |> to_string() available = Map.get(assigns, :available, true) confirm = Map.get(assigns, :confirm) params = assigns |> Map.drop([ :__changed__, :inner_block, :name, :available, :confirm, :desc, :params, :agent_only, :risk, :effects, :reversible, :idempotent, :examples, :success, :class, :id, :type, :navigate ]) |> 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 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 %{ capabilities: :all, projections: [:state, :regions, :actions], expires_in: :timer.minutes(15), require_version: true } 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) 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) 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 -> 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) 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 -> navigate = component_attr(attrs, "navigate", env) name = component_action_name!(attrs, env) desc = component_attr(attrs, "desc", env) params = component_attr(attrs, "params", env) cond do not is_nil(navigate) -> [navigate_declaration(attrs, env, name, navigate, desc)] 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 -> [ %{ name: name, desc: desc, params: params, 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) } |> Enum.reject(fn {_key, value} -> is_nil(value) end) |> Map.new() ] 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) } |> 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 a navigate target # when no explicit name is given. defp component_action_name!(attrs, env) do case component_attr(attrs, "name", env) do nil -> case component_attr(attrs, "navigate", env) do path when is_binary(path) -> navigate_action_name(path) nil -> raise CompileError, file: env.file, line: env.line, description: "Dialup action must provide a compile-time name or navigate target" _other -> raise CompileError, file: env.file, line: env.line, description: "Dialup action navigate target must be a compile-time string" end name -> name 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