lib/ash_sdui.ex

defmodule AshSDUI do
  @moduledoc """
  Server-Driven UI for Phoenix LiveView applications backed by Ash resources.

  AshSDUI lets you define UI layouts as data — either in code or persisted in your
  database — and render them dynamically in LiveView without redeploying.

  ## Key modules

  - `AshSDUI.Component` — macro for declaring and registering SDUI components
  - `AshSDUI.Registry` — ETS-backed registry of all discovered components
  - `AshSDUI.Layout` — code-based layout registry for static or config-driven screens
  - `AshSDUI.UINode` — Ash resource for persisting dynamic layout trees
  - `AshSDUI.Renderer` — builds a `TreeNode` tree from a layout name or `UINode` records
  - `AshSDUI.Cache` — ETS-backed cache with automatic invalidation on `UINode` changes
  - `AshSDUI.Notifier` — Ash notifier that evicts cache entries on resource changes
  - `AshSDUI.Calculations.ResolveSubject` — resolves `{subject_resource, subject_id}` to a live record
  - `AshSDUI.Components.SDUIRoot` — Phoenix component that recursively renders a tree

  ## Usage in a LiveView

  Add `use AshSDUI` with a lookup strategy and call `<.sdui_root />` in your template:

      defmodule MyAppWeb.Live.PlayerDashboard do
        use MyAppWeb, :live_view
        use AshSDUI, lookup: {:from_params, :name}

        def render(assigns) do
          ~H\"""
          <%= if @__sdui_tree__ do %>
            <.sdui_root />
          <% else %>
            <div>Layout not found</div>
          <% end %>
          \"""
        end
      end

  You must reference `@__sdui_tree__` in your template so Phoenix includes it in the
  assigns passed to `sdui_root`. The injected `mount/3` is declared `defoverridable`
  — you can override it to add your own socket assigns.

  ## Lookup strategies

  - `{:from_params, :name}` — reads the layout name from the socket params map
  - `{:static, "layout-name"}` — always renders the named layout

  ## Defining a component

      defmodule MyAppWeb.Components.Player.ScoreCard do
        use MyAppWeb, :live_component
        use AshSDUI.Component, fragment: \"""
          fragment PlayerScoreCardData on Player {
            displayName
            currentScore
          }
        \"""

        def render(assigns) do
          ~H\"""
          <div>
            <h2><%= @subject.display_name %></h2>
            <p>Score: <%= @subject.current_score %></p>
          </div>
          \"""
        end
      end

  Components are registered automatically under a name derived from the module
  (e.g., `"Player.ScoreCard@v1"`). Set `@version "v2"` before `use AshSDUI.Component`
  to override the default version suffix.

  See the [README](https://hexdocs.pm/ash_sdui) for full usage, layout definitions,
  UINode actions, and caching details.
  """

  defmacro __using__(opts) do
    lookup = Keyword.fetch!(opts, :lookup)

    quote do
      @impl true
      def mount(params, session, socket) do
        name = AshSDUI.__resolve_name__(unquote(lookup), params)

        case AshSDUI.Renderer.to_tree(name) do
          {:ok, tree} ->
            {:ok, assign(socket, :__sdui_tree__, tree)}

          {:error, reason} ->
            {:ok, assign(socket, :__sdui_tree__, nil)}
        end
      end

      defoverridable mount: 3

      def sdui_root(assigns) do
        tree = Map.get(assigns, :tree) || Map.get(assigns, :__sdui_tree__)
        AshSDUI.Components.SDUIRoot.render(Map.put(assigns, :tree, tree))
      end
    end
  end

  @doc false
  def __resolve_name__({:from_params, key}, params) do
    Map.get(params, to_string(key))
  end

  def __resolve_name__({:static, name}, _params) do
    name
  end
end