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\"\"\"

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. ## 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