lib/inertia/controller.ex

defmodule Inertia.Controller do
  @moduledoc """
  Controller functions for rendering Inertia.js responses.
  """

  require Logger

  alias Inertia.SSR.RenderError
  alias Inertia.SSR

  import Phoenix.Controller
  import Plug.Conn

  @title_regex ~r/<title inertia>(.*?)<\/title>/

  @type lazy() :: {:lazy, fun()}
  @type always() :: {:keep, fun()}

  @doc """
  Marks a prop value as lazy, which means it will only get evaluated if
  explicitly requested in a partial reload.

  Lazy props will _only_ be included the when explicitly requested in a partial
  reload. If you want to include the prop on first visit, you'll want to use a
  bare anonymous function or named function reference instead.

  ```elixir
  conn
  # ALWAYS included on first visit...
  # OPTIONALLY included on partial reloads...
  # ALWAYS evaluated...
  |> assign_prop(:cheap_thing, cheap_thing())

  # ALWAYS included on first visit...
  # OPTIONALLY included on partial reloads...
  # ONLY evaluated when needed...
  |> assign_prop(:expensive_thing, fn -> calculate_thing() end)
  |> assign_prop(:another_expensive_thing, &calculate_another_thing/0)

  # NEVER included on first visit...
  # OPTIONALLY included on partial reloads...
  # ONLY evaluated when needed...
  |> assign_prop(:super_expensive_thing, inertia_lazy(fn -> calculate_thing() end))
  ```
  """
  @spec inertia_lazy(fun :: fun()) :: lazy()
  def inertia_lazy(fun) when is_function(fun), do: {:lazy, fun}

  def inertia_lazy(_) do
    raise ArgumentError, message: "inertia_lazy/1 only accepts a function argument"
  end

  @doc """
  Marks a prop value as "always included", which means it will be included in
  the props on initial page load and subsequent partial loads (even when it's
  not explicitly requested).
  """
  @spec inertia_always(value :: any()) :: always()
  def inertia_always(value), do: {:keep, value}

  @doc """
  Assigns a prop value to the Inertia page data.
  """
  @spec assign_prop(Plug.Conn.t(), atom(), any()) :: Plug.Conn.t()
  def assign_prop(conn, key, value) do
    shared = conn.private[:inertia_shared] || %{}
    put_private(conn, :inertia_shared, Map.put(shared, key, value))
  end

  @doc """
  Renders an Inertia response.
  """
  @spec render_inertia(Plug.Conn.t(), component :: String.t()) :: Plug.Conn.t()
  @spec render_inertia(Plug.Conn.t(), component :: String.t(), props :: map()) :: Plug.Conn.t()

  def render_inertia(conn, component, props \\ %{}) do
    shared = conn.private[:inertia_shared] || %{}

    # Only render partial props if the partial component matches the current page
    is_partial = conn.private[:inertia_partial_component] == component
    only = if is_partial, do: conn.private[:inertia_partial_only], else: []
    except = if is_partial, do: conn.private[:inertia_partial_except], else: []

    props =
      shared
      |> Map.merge(props)
      |> resolve_props(only: only, except: except)

    conn
    |> put_private(:inertia_page, %{component: component, props: props})
    |> send_response()
  end

  # Private helpers

  defp resolve_props(map, opts) when is_map(map) do
    key_values =
      map
      |> Map.to_list()
      |> Enum.reduce([], fn {key, value}, acc ->
        path = if opts[:path], do: "#{opts[:path]}.#{key}", else: to_string(key)
        opts = Keyword.put(opts, :path, path)
        resolved_value = resolve_props(value, opts)

        if resolved_value == :skip do
          acc
        else
          [{key, resolved_value} | acc]
        end
      end)

    case {opts[:path], key_values} do
      {nil, key_values} -> Map.new(key_values)
      {_, [_ | _]} -> Map.new(key_values)
      {_, _} -> :skip
    end
  end

  defp resolve_props({:lazy, value}, opts) do
    if Enum.member?(opts[:only], opts[:path]) do
      resolve_props(value, opts)
    else
      :skip
    end
  end

  defp resolve_props({:keep, value}, opts) do
    opts = Keyword.put(opts, :keep, true)
    resolve_props(value, opts)
  end

  defp resolve_props(fun, opts) when is_function(fun, 0) do
    if skip?(opts) do
      :skip
    else
      fun.()
    end
  end

  defp resolve_props(value, opts) do
    if skip?(opts) do
      :skip
    else
      value
    end
  end

  defp skip?(opts) do
    path = opts[:path]
    only = opts[:only]
    except = opts[:except]
    keep = opts[:keep]

    cond do
      keep -> false
      length(only) > 0 && !Enum.member?(only, path) -> true
      length(except) > 0 && Enum.member?(except, path) -> true
      true -> false
    end
  end

  defp send_response(%{private: %{inertia_request: true}} = conn) do
    conn
    |> put_status(200)
    |> put_resp_header("x-inertia", "true")
    |> put_resp_header("vary", "X-Inertia")
    |> json(inertia_assigns(conn))
  end

  defp send_response(conn) do
    if ssr_enabled?() do
      case SSR.call(inertia_assigns(conn)) do
        {:ok, %{"head" => head, "body" => body}} ->
          send_ssr_response(conn, head, body)

        {:error, message} ->
          if raise_on_ssr_failure() do
            raise RenderError, message: message
          else
            Logger.error("SSR failed, falling back to CSR\n\n#{message}")
            send_csr_response(conn)
          end
      end
    else
      send_csr_response(conn)
    end
  end

  defp compile_head(%{assigns: %{inertia_head: current_head}} = conn, incoming_head) do
    {titles, other_tags} = Enum.split_with(current_head ++ incoming_head, &(&1 =~ @title_regex))

    conn
    |> assign(:inertia_head, other_tags)
    |> update_page_title(Enum.reverse(titles))
  end

  defp update_page_title(conn, [title_tag | _]) do
    [_, page_title] = Regex.run(@title_regex, title_tag)
    assign(conn, :page_title, page_title)
  end

  defp update_page_title(conn, _), do: conn

  defp send_ssr_response(conn, head, body) do
    conn
    |> put_view(Inertia.HTML)
    |> compile_head(head)
    |> assign(:body, body)
    |> render(:inertia_ssr)
  end

  defp send_csr_response(conn) do
    conn
    |> put_view(Inertia.HTML)
    |> render(:inertia_page, inertia_assigns(conn))
  end

  defp inertia_assigns(conn) do
    %{
      component: conn.private.inertia_page.component,
      props: conn.private.inertia_page.props,
      url: request_path(conn),
      version: conn.private.inertia_version
    }
  end

  defp request_path(conn) do
    IO.iodata_to_binary([conn.request_path, request_url_qs(conn.query_string)])
  end

  defp request_url_qs(""), do: ""
  defp request_url_qs(qs), do: [??, qs]

  defp ssr_enabled? do
    Application.get_env(:inertia, :ssr, false)
  end

  defp raise_on_ssr_failure do
    Application.get_env(:inertia, :raise_on_ssr_failure, true)
  end
end