defmodule Nex.Handler.Page do @moduledoc false import Plug.Conn alias Nex.Handler.Errors def handle(conn, method, path) do case method do :get -> case Nex.RouteDiscovery.resolve(:pages, path) do {:ok, module, params} -> handle_page_render(conn, module, Map.merge(conn.params, params)) :error -> Errors.send_error_page(conn, 404, "Page Not Found", nil) end method when method in [:post, :delete, :put, :patch] -> case Nex.CSRF.validate(conn) do :ok -> referer_path = referer_path(conn) case Nex.RouteDiscovery.resolve(:action, path, referer_path) do {:ok, module, action, params} -> handle_page_action(conn, module, action, Map.merge(conn.params, params)) :error -> Errors.send_error_page(conn, 404, "Action Not Found", nil) end {:error, :missing_token} -> Errors.send_error_page(conn, 403, "CSRF token missing", nil) {:error, :invalid_token} -> Errors.send_error_page(conn, 403, "CSRF token invalid", nil) end _ -> Errors.send_error_page(conn, 405, "Method Not Allowed", nil) end end def page_id_from_request(conn) do case get_req_header(conn, "x-nex-page-id") do [page_id | _] -> page_id [] -> generate_page_id() end end defp handle_page_render(conn, module, params) do page_id = Nex.Store.generate_page_id() Nex.Store.set_page_id(page_id) csrf_token = Nex.CSRF.generate_token() case call_mount(module, params) do {:redirect, path} -> conn |> put_resp_header("location", path) |> send_resp(302, "") {:redirect, path, status} -> conn |> put_resp_header("location", path) |> send_resp(status, "") :not_found -> Errors.send_error_page(conn, 404, "Page Not Found", nil) %{} = assigns -> render_page(conn, module, assigns, page_id, csrf_token) end end defp call_mount(module, params) do if function_exported?(module, :mount, 1), do: module.mount(params), else: %{} end defp render_page(conn, module, assigns, page_id, csrf_token) do assigns = assigns |> Map.put(:_page_id, page_id) |> Map.put(:_csrf_token, csrf_token) if function_exported?(module, :render, 1) do content = module.render(assigns) content_html = Phoenix.HTML.Safe.to_iodata(content) |> IO.iodata_to_binary() page_path = "/" <> Enum.join(conn.path_info, "/") nex_script = build_nex_script(page_id, csrf_token, page_path) # Phase 1: _app.ex wraps page content (receives all assigns) app_html = wrap_with_app(module, assigns, content_html) # Phase 2: _document.ex provides HTML shell doc_html = wrap_with_document(assigns, app_html <> nex_script) # Phase 3: CSRF injection final_html = inject_csrf(doc_html, csrf_token) conn |> put_resp_content_type("text/html") |> send_resp(200, final_html) else send_resp(conn, 500, "Page module missing render/1") end end defp handle_page_action(conn, module, action, params) do page_id = page_id_from_request(conn) Nex.Store.set_page_id(page_id) req = Nex.Req.from_plug_conn(conn, params) case Nex.Utils.safe_to_existing_atom(action) do {:ok, action_atom} -> if function_exported?(module, action_atom, 1) do result = apply(module, action_atom, [req]) send_action_response(conn, result) else send_resp(conn, 404, "Action not found: #{action}") end :error -> send_resp(conn, 404, "Action not found: #{action}") end end defp send_action_response(conn, :empty) do send_resp(conn, 200, "") end defp send_action_response(conn, {:redirect, to}) do conn |> put_resp_header("hx-redirect", to) |> send_resp(200, "") end defp send_action_response(conn, {:refresh, _}) do conn |> put_resp_header("hx-refresh", "true") |> send_resp(200, "") end defp send_action_response(conn, %Nex.Response{ status: status, headers: headers, body: body, content_type: content_type }) do is_redirect = status in [301, 302, 303, 307, 308] is_htmx = get_req_header(conn, "hx-request") != [] conn = Enum.reduce(headers, conn, fn {key, value}, conn -> put_resp_header(conn, key, value) end) conn = if content_type, do: put_resp_content_type(conn, content_type), else: conn cond do is_redirect and is_htmx -> location = Map.get(headers, "location", "/") conn |> put_resp_header("hx-redirect", location) |> send_resp(200, "") is_redirect -> send_resp(conn, status, "") is_struct(body, Phoenix.LiveView.Rendered) -> html = Phoenix.HTML.Safe.to_iodata(body) |> IO.iodata_to_binary() send_resp(conn, status, html) is_function(body, 1) -> conn = send_chunked(conn, status) Process.put(:nex_chunked_conn, conn) body.(fn chunk -> current = Process.get(:nex_chunked_conn) case Plug.Conn.chunk(current, chunk) do {:ok, new_conn} -> Process.put(:nex_chunked_conn, new_conn) new_conn _ -> current end end) Process.delete(:nex_chunked_conn) || conn true -> body_str = if is_binary(body), do: body, else: Jason.encode!(body) send_resp(conn, status, body_str) end end defp send_action_response(conn, heex) do html = Phoenix.HTML.Safe.to_iodata(heex) |> IO.iodata_to_binary() conn |> put_resp_content_type("text/html") |> send_resp(200, html) end # --- Two-phase layout rendering --- defp wrap_with_app(page_module, assigns, content_html) do app_module = resolve_app_module(page_module) if app_module && function_exported?(app_module, :render, 1) do app_assigns = assigns |> Map.put(:inner_content, content_html) |> Map.put_new(:title, "Nex App") app_module.render(app_assigns) |> to_html_binary() else content_html end end defp wrap_with_document(assigns, inner_html) do doc_module = resolve_document_module() if doc_module && function_exported?(doc_module, :render, 1) do doc_assigns = %{inner_content: inner_html, title: Map.get(assigns, :title, "Nex App")} doc_module.render(doc_assigns) |> to_html_binary() else default_document(inner_html, Map.get(assigns, :title, "Nex App")) end end defp default_document(inner_html, title) do """