Packages

WebSocket-first Elixir framework with auto-generated HTTP MCP APIs

Current section

Files

Jump to
dialup lib layout.ex
Raw

lib/layout.ex

defmodule Dialup.Layout do
@moduledoc """
The behaviour and macro for Dialup layout modules.
A layout wraps one or more pages with shared chrome (navigation, footer, etc.).
Layouts are discovered automatically based on the directory hierarchy:
app/
├── layout.ex # wraps all pages under app/
└── blog/
├── layout.ex # wraps all pages under app/blog/ (nested)
└── [slug]/
└── page.ex
## Usage
defmodule MyApp.App.Layout do
use Dialup.Layout
# Called once when the WebSocket connection is established.
# Use it to set session-scoped data (e.g. the current user).
def mount(session) do
user = Repo.get(User, session[:user_id])
{:ok, Map.put(session, :current_user, user)}
end
def render(assigns) do
~H\"\"\"
<nav>{@current_user.name}</nav>
<main>{raw(@inner_content)}</main>
\"\"\"
end
end
## Callbacks
- `mount/1` — optional; called once when the WebSocket connection is established.
Receives the current session map (empty `%{}` for the root layout, or whatever the
parent layout returned). Return `{:ok, new_session}`.
- `render/1` — renders the layout HTML using HEEx. Use `{raw(@inner_content)}` to
inject the child content.
## Colocation CSS
Place a `layout.css` file in the same directory as `layout.ex`. The styles are
automatically scoped and applied to all pages under that directory at compile time.
## Nesting
Layout `mount/1` calls are chained from outermost to innermost. Each layout receives
the session returned by its parent and may enrich it further.
## Navigation actions
A layout may declare navigation links with `<.dialup_action navigate="/path">`, exactly like a
page. These render the shared chrome links for humans and are merged into the MCP tool catalog of
every page wrapped by the layout, so an attached agent gets the same site-wide navigation a human
sees in the nav bar. Layouts support navigation actions only; event-handling actions
(`<.dialup_action>` with `desc`/`params`) belong on a page that implements `handle_event/3`.
"""
@callback render(assigns :: map()) :: Phoenix.LiveView.Rendered.t()
# session :: 親レイアウトが設定したsession(rootレイアウトは %{} を受け取る)
@callback mount(session :: map()) :: {:ok, map()}
@optional_callbacks mount: 1
defmacro __using__(_opts) do
quote do
@behaviour Dialup.Layout
import Phoenix.Component
import Phoenix.HTML, only: [raw: 1]
import Dialup.Page,
only: [overwrite: 2, set_default: 2, declare_action: 1, dialup_action: 1]
Module.register_attribute(__MODULE__, :dialup_actions, accumulate: true)
# デフォルト実装:何もしない(mountを定義しないlayoutはsessionに何も追加しない)
def mount(session), do: {:ok, session}
defoverridable mount: 1
@before_compile Dialup.Layout
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)
render_quote =
cond do
has_render ->
quote do
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
actions = Dialup.Page.actions_from_env(env)
Dialup.Page.validate_navigation_actions!(env, actions)
quote do
unquote(render_quote)
unquote(css_quote)
def __dialup_actions__, do: unquote(Macro.escape(actions))
end
end
end