Current section
Files
Jump to
Current section
Files
lib/page.ex
defmodule Dialup.Page do
@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\"\"\"
<h1>Count: {@count}</h1>
<button ws-event="increment">+1</button>
\"\"\"
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 `<title>`. Return `nil` to use the
application default.
## 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`, and `subscribe/2`.
"""
@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
@optional_callbacks mount: 1, handle_info: 2, page_title: 1
@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]
# デフォルト実装
def handle_event(_event, _value, assigns), do: {:noreply, assigns}
def handle_info(_msg, assigns), do: {:noreply, assigns}
def page_title(_assigns), do: nil
defoverridable handle_event: 3, handle_info: 2, page_title: 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)
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)
end
end
end