defmodule Skuld.PageMachine.Contract do @moduledoc """ Typed protocol contract for PageMachine spindle ↔ LiveView communication. `use Skuld.PageMachine.Contract` imports `defspindle`, `defevent`, and `defyield` macros to declare the typed interface between a page's spindles and its LiveView. Each declaration is validated at compile time. The protocol module acts as the single source of truth for event routing and yield shapes. ## Usage defmodule MyApp.StoreProtocol do use Skuld.PageMachine.Contract defspindle Products do defevent "search", SearchEvent, params: [query: String.t()] defevent "filter", FilterEvent, params: [filters: map()] defevent "buy", BuyEvent, params: [product: Product.t()] defyield :browsing defyield results(products: [Product.t()], total: integer()) end defspindle Checkout do defevent "submit_shipping", ShippingEvent, params: [shipping: map()] defevent "submit_payment", PaymentEvent, params: [payment: map()] defyield :shipping defyield :payment end end Events with an explicit struct name generate a typed struct module under the spindle module (`StoreProtocol.Products.SearchEvent`). The auto-generated `handle_event` wraps params into the struct before resuming the spindle, giving typed receive-side pattern matching. Events without a struct name pass `{event_name, params}` as the resume value (LiveView convention). The spindle key is the module atom (`StoreProtocol.Products`) — the same module where yield functions are generated: StoreProtocol.Products.results(products: prods, total: n) StoreProtocol.Checkout.shipping() ## Integration with PageMachine use Skuld.PageMachine, protocol: MyApp.StoreProtocol, on_yield: &handle_yield/3 The `:protocol` option auto-generates `handle_event/3` clauses from the protocol's event declarations. """ @doc false defmacro __using__(_opts) do quote do import Skuld.PageMachine.Contract, only: [ defspindle: 2, defevent: 1, defevent: 2, defevent: 3, defyield: 1, defnotify: 1 ] Module.register_attribute(__MODULE__, :pm_events, accumulate: true) Module.register_attribute(__MODULE__, :pm_yields, accumulate: true) Module.register_attribute(__MODULE__, :pm_spindles, accumulate: true) @before_compile Skuld.PageMachine.Contract end end @doc """ Open a spindle block. Inside the block, `defevent` and `defyield` infer the spindle from the enclosing `defspindle` context — no need to repeat the spindle key. The spindle name becomes the module atom under the protocol module (e.g., `defspindle Products` → `StoreProtocol.Products`), which is both the spindle key for event routing and the module where typed yield functions are generated. ## Example defspindle Products do defevent "search", SearchEvent, params: [query: String.t()] defyield :browsing defyield results(products: [Product.t()], total: integer()) end """ defmacro defspindle(name, do: block) do spindle_name = case name do {atom_name, _meta, nil} when is_atom(atom_name) -> atom_name {:__aliases__, _meta, segments} -> List.last(segments) end spindle_atom = Module.concat(__CALLER__.module, spindle_name) Module.put_attribute(__CALLER__.module, :current_spindle, spindle_atom) quote do @pm_spindles unquote(spindle_atom) unquote(block) Module.delete_attribute(__MODULE__, :current_spindle) end end @doc """ Declare a LiveView event routed to the current spindle. Without params, the spindle receives `{event_name, params}` (standard LiveView convention). With params and a struct name, a typed event struct is generated and the spindle receives it directly — enabling typed receive-side pattern matching. ## Syntax defevent "event_name" defevent "event_name", StructName, params: [field: type(), ...] """ defmacro defevent(event_name) when is_binary(event_name) do build_defevent(event_name, nil, [], __CALLER__) end defmacro defevent(event_name, opts) when is_list(opts) do params = Keyword.get(opts, :params) build_defevent(event_name, nil, params || [], __CALLER__) end defmacro defevent(event_name, struct_name, opts) when is_list(opts) do params = Keyword.get(opts, :params) struct_atom = resolve_struct_name(struct_name) build_defevent(event_name, struct_atom, params || [], __CALLER__) end defp resolve_struct_name({:__aliases__, _meta, segments}), do: List.last(segments) defp resolve_struct_name({atom_name, _meta, nil}) when is_atom(atom_name), do: atom_name defp resolve_struct_name(atom) when is_atom(atom), do: atom defp build_defevent(event_name, struct_name, params, caller) do spindle = Module.get_attribute(caller.module, :current_spindle) unless spindle do raise CompileError, description: "defevent outside a defspindle block", file: caller.file end event = %{ event: clean_event_name(event_name), spindle: spindle, struct_name: struct_name, params: params } escaped = Macro.escape(event) quote do @pm_events unquote(escaped) end end @doc """ Declare a yield from the current spindle to the LiveView. ## Syntax defyield :tag defyield tag(key: type(), ...) """ defmacro defyield(tag) when is_atom(tag) do build_defyield(tag, [], __CALLER__) end defmacro defyield({tag, _meta, args}) when is_atom(tag) do params = extract_keyword_params(args) build_defyield(tag, params, __CALLER__) end defp extract_keyword_params([keyword_list]) when is_list(keyword_list) do Enum.map(keyword_list, fn {key, val} when is_atom(key) -> {key, val} end) end defp build_defyield(tag, params, caller) do yield = %{ spindle: get_spindle!(caller), tag: tag, params: params, nest: :yield } escaped = Macro.escape(yield) quote do @pm_yields unquote(escaped) end end @doc """ Declare a fire-and-forget notification from the current spindle. Same syntax as `defyield`, but generates a function that calls `FiberYield.notify/1` instead of `Yield.yield/1` — the spindle surfaces the value to the caller without pausing. ## Syntax defnotify :tag defnotify tag(key: type(), ...) """ defmacro defnotify(tag) when is_atom(tag) do build_defnotify(tag, [], __CALLER__) end defmacro defnotify({tag, _meta, args}) when is_atom(tag) do params = extract_keyword_params(args) build_defnotify(tag, params, __CALLER__) end defp build_defnotify(tag, params, caller) do yield = %{ spindle: get_spindle!(caller), tag: tag, params: params, nest: :notify } escaped = Macro.escape(yield) quote do @pm_yields unquote(escaped) end end defp get_spindle!(caller) do case Module.get_attribute(caller.module, :current_spindle) do nil -> raise CompileError, description: "defyield outside a defspindle block", file: caller.file spindle -> spindle end end @doc false defmacro __before_compile__(env) do events = Module.get_attribute(env.module, :pm_events) |> Enum.reverse() yields = Module.get_attribute(env.module, :pm_yields) |> Enum.reverse() validate_events!(events, env) validate_yields!(yields, env) spindle_modules = generate_spindle_modules(env.module, yields) event_struct_modules = generate_event_struct_modules(env.module, events) introspection = generate_introspection(events, yields) event_list = generate_event_list(env.module, events) quote do unquote_splicing(spindle_modules) unquote_splicing(event_struct_modules) unquote(introspection) unquote(event_list) end end defp validate_events!(events, env) do if events == [] do raise CompileError, description: "#{inspect(env.module)} has no defevent declarations. " <> "Add at least one defevent to define the LiveView → spindle event contract.", file: env.file end Enum.each(events, fn %{event: event} -> unless is_binary(event) and byte_size(event) > 0 do raise CompileError, description: "defevent event name must be a non-empty string", file: env.file end end) event_names = Enum.map(events, & &1.event) unique = MapSet.new(event_names) |> MapSet.size() if unique != length(event_names) do dups = event_names -- Enum.uniq(event_names) raise CompileError, description: "Duplicate event names: #{inspect(dups)}", file: env.file end end defp validate_yields!(yields, env) do if yields == [] do raise CompileError, description: "#{inspect(env.module)} has no defyield declarations. " <> "Add at least one defyield to define the spindle → LiveView yield contract.", file: env.file end tag_pairs = Enum.map(yields, fn %{spindle: s, tag: t} -> {s, t} end) unique = MapSet.new(tag_pairs) |> MapSet.size() if unique != length(yields) do dups = tag_pairs -- Enum.uniq(tag_pairs) raise CompileError, description: "Duplicate spindle/tag pairs in defyield: #{inspect(dups)}", file: env.file end end defp generate_spindle_modules(_protocol_module, yields) do yields |> Enum.group_by(&{&1.spindle, &1.nest}) |> Enum.map(fn {{spindle_module, nest}, spindle_yields} -> nest_module = Module.concat(spindle_module, to_pascal_case(nest)) struct_defs = Enum.map(spindle_yields, &generate_yield_struct(nest_module, &1)) yield_fns = Enum.map(spindle_yields, &generate_yield_fn(nest_module, &1)) quote do defmodule unquote(nest_module) do @moduledoc false unquote_splicing(struct_defs) unquote_splicing(yield_fns) end end end) end defp generate_yield_struct(spindle_module, %{tag: tag, params: params}) do struct_name = to_pascal_case(tag) struct_module = Module.concat(spindle_module, struct_name) fields = Enum.map(params, fn {name, _type} -> name end) field_defaults = Enum.map(fields, &{&1, nil}) type_spec = Enum.map(params, fn {name, type} -> {name, type} end) quote do defmodule unquote(struct_module) do @moduledoc false @type t :: %__MODULE__{ unquote_splicing(type_spec) } defstruct unquote(field_defaults) end end end defp generate_yield_fn(spindle_module, %{tag: tag, params: params, nest: nest}) do struct_name = to_pascal_case(tag) struct_module = Module.concat(spindle_module, struct_name) {effect_mod, effect_fun} = case nest do :notify -> {Skuld.Effects.FiberYield, :notify} :yield -> {Skuld.Effects.Yield, :yield} end if params == [] do quote do @spec unquote(tag)() :: unquote(struct_module).t() def unquote(tag)() do unquote(effect_mod).unquote(effect_fun)(%unquote(struct_module){}) end end else field_names = Enum.map(params, fn {name, _type} -> name end) fetch_bindings = Enum.map(field_names, fn name -> var = Macro.var(name, nil) quote do unquote(var) = Keyword.fetch!(opts, unquote(name)) end end) field_kvs = Enum.map(field_names, fn name -> {name, Macro.var(name, nil)} end) quote do @spec unquote(tag)(keyword()) :: Skuld.Comp.Types.computation() def unquote(tag)(opts) do unquote_splicing(fetch_bindings) struct = struct(unquote(struct_module), unquote(field_kvs)) unquote(effect_mod).unquote(effect_fun)(struct) end end end end defp generate_event_struct_modules(_protocol_module, events) do events |> Enum.filter(fn %{struct_name: sn} -> sn != nil end) |> Enum.map(fn %{spindle: spindle, struct_name: struct_name, params: params} -> struct_module = Module.concat(spindle, struct_name) fields = Enum.map(params, fn {name, _type} -> name end) field_defaults = Enum.map(fields, &{&1, nil}) type_spec = Enum.map(params, fn {name, type} -> {name, type} end) quote do defmodule unquote(struct_module) do @moduledoc false @type t :: %__MODULE__{ unquote_splicing(type_spec) } defstruct unquote(field_defaults) end end end) end defp generate_introspection(events, yields) do escaped_events = Macro.escape(events) escaped_yields = Macro.escape(yields) quote do @doc """ Returns the list of defevent declarations as maps. """ @spec __protocol_events__() :: [map()] def __protocol_events__, do: unquote(escaped_events) @doc """ Returns the list of defyield declarations as maps. """ @spec __protocol_yields__() :: [map()] def __protocol_yields__, do: unquote(escaped_yields) end end defp generate_event_list(_protocol_module, events) do entries = Enum.map(events, fn %{ event: event, spindle: spindle, struct_name: struct_name, params: params } -> {event, spindle, struct_name, params} end) escaped = Macro.escape(entries) quote do @doc false def __pm_events__, do: unquote(escaped) end end @doc false def to_pascal_case(atom) when is_atom(atom) do atom |> Atom.to_string() |> String.split("_") |> Enum.map_join(fn s -> String.capitalize(s) end) |> String.to_atom() end defp clean_event_name(event) when is_binary(event), do: event end