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\"\"\"
{raw(@inner_content)}
\"\"\" 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