Hex.pm Hexdocs.pm License: MIT

Host-agnostic page builder library for Phoenix LiveView apps.

Athanor gives you a turn-key drag-edit page editor — canvas, components panel, configure panel, viewport switcher, formatting tab — that you mount inside your own LiveView with a use macro. You declare your components as plain Elixir modules and Athanor handles the rest: serialization, render dispatch, form generation, edit chrome.

It is not an admin CMS. It does not ship a database, an HTTP endpoint, or an opinion about where pages get stored. It hands you a content tree (%{"content" => [%{"id" => _, "type" => _, "props" => _}, ...]}) and trusts your app to load/save it.

Inspired by Puck.js (React) — Athanor brings its resolveFields/resolveData mental model to the BEAM.

Status: early — 0.x. Public API may shift between minor versions. See CHANGELOG.md for what changed. Production-used by Amplify.

Install

def deps do
  [
    {:athanor, "~> 0.1"}
  ]
end

If you use Tailwind v4, point @source at Athanor so utility classes in the editor chrome get scanned:

/* assets/css/app.css */
@source "../../deps/athanor/lib/**/*.*ex";

60-second tour

1. Declare a component

defmodule MyApp.Components.Hero do
  use Athanor.Component
  use Phoenix.Component

  @impl Athanor.Component
  def metadata, do: %{type: "hero", label: "Hero", icon: "fa-image"}

  @impl Athanor.Component
  def fields do
    [
      {"title", :text, label: "Title"},
      {"subtitle", :textarea, label: "Subtitle"}
    ]
  end

  @impl Athanor.Component
  def render(:live, node, _ctx) do
    assigns = node["props"]
    ~H"""
    <section class="py-24 text-center">
      <h1 class="text-5xl font-bold">{@title}</h1>
      <p class="mt-4 text-lg">{@subtitle}</p>
    </section>
    """
  end
end

2. Register it

# config/config.exs
config :athanor, components: [MyApp.Components.Hero]

3. Mount the editor

Pages store one field — editor_content — that is the whole tree (%{"content" => [...]}). Editor and storefront both read/write the same map.

defmodule MyAppWeb.PageEditorLive do
  use Athanor.Editor.Live

  @impl Athanor.Editor
  def load(%{"id" => id}, _session, _socket) do
    page = MyApp.Pages.get_page!(id)

    {:ok,
     %{
       content: page.editor_content || %{"content" => []},
       metadata: %{},
       ctx_assigns: %{}
     }}
  end

  @impl Athanor.Editor
  def save(socket, %{content: content}) do
    MyApp.Pages.update_page(socket.assigns.page, %{editor_content: content})
  end
end

Wire the route:

live "/admin/pages/:id/edit", MyAppWeb.PageEditorLive

4. Render the saved page on the storefront

Same editor_content map, no editor chrome — Athanor.Renderer.tree/1 dispatches each node to its component's render/3.

defmodule MyAppWeb.PageLive do
  use MyAppWeb, :live_view

  def mount(%{"slug" => slug}, _session, socket) do
    page = MyApp.Pages.get_page_by_slug!(slug)
    {:ok, assign(socket, :page, page)}
  end

  def render(assigns) do
    ~H"""
    <Athanor.Renderer.tree
      tree={@page.editor_content}
      ctx={Athanor.Ctx.new()}
      edit_mode={false}
    />
    """
  end
end

That's the whole integration. The editor canvas, components palette, auto-generated config forms (one input per fields/0 entry), the formatting tab (alignment / colors / padding / margin / borders), a viewport switcher, and a Save button all come from use Athanor.Editor.Live. Your storefront renders the same tree without any of that chrome.

The editor supports drag-and-drop out of the box: drag from the components palette onto the canvas, reorder canvas items by dragging them up or down, drag children in and out of Columns zones. The server-side handler is built in; you only need to register the two JS hooks that ship with the library:

// assets/js/app.js
import { AthanorHooks } from "athanor"

let liveSocket = new LiveSocket("/live", Socket, {
  hooks: { ...AthanorHooks /* your other hooks */ }
})

Hooks use native HTML5 DnD — no JS dependency.

Concepts

ModuleRole
Athanor.TreePure-data manipulation of the content tree (insert, move, remove, find)
Athanor.ComponentBehaviour + use macro for declaring components
Athanor.RegistryRuntime lookup of components by "type" string
Athanor.RendererDispatches each node to its component's render/3
Athanor.CtxRender/edit context (account_id, brand_id, edit_mode?, etc.)
Athanor.Editor.Liveuse macro that injects the LiveView
Athanor.EditorFunction components (canvas, components_panel, config_panel, shell) for custom layouts
Athanor.FieldsAuto-renders a component's fields/0 schema into form inputs
Athanor.FieldBehaviour-style contract for custom field LiveComponents
Athanor.AutoEditorFormLiveComponent wrapping the auto-form plumbing

Field types

fields/0 returns a list of {key, type, opts} tuples. Built-in types:

  • :text — text input
  • :textarea — textarea
  • :number — number input with optional min:/max:
  • :select — dropdown driven by options: [{label, value}, ...] (or a function of Ctx)
  • :color — color picker with a Clear button
  • :checkbox — boolean
  • :custom — mounts your own LiveComponent (image picker, product selector, rich-text editor, anything) by passing module: MyApp.Foo

Add if: fn props -> boolean end to any field to conditionally show/hide it.

Dynamic fields & data

Override resolve_fields/2 to compute the schema at render time — e.g. to add fields based on the current props["variant"]:

def resolve_fields(props, _ctx) do
  fields() ++
    case props["mode"] do
      "advanced" -> [{"target", :text, label: "Target URL"}]
      _ -> []
    end
end

Override resolve_data/2 to compute derived props after every change — e.g. to look up display data from an id:

def resolve_data(_old, new) do
  case new["product_id"] do
    nil -> new
    id -> Map.put(new, "product_name", MyApp.Products.get_name(id))
  end
end

Same shapes as Puck.js's resolveFields / resolveData.

Page-level settings

Title, description, slug, social image, anything that lives outside the component tree — declare it as a regular Athanor.Component and pass it as :page_settings_component to your editor mount. It auto-renders at the top of the sidebar and round-trips through metadata in your save handler.

What Athanor does not do

  • Persistence. You load/save. Postgres, Mnesia, S3 — your call.
  • HTTP routes. Mount the LiveView wherever you want.
  • Auth. Your LiveView's on_mount chain runs first.
  • Built-in components. A small primitive set ships (Button, Columns, Divider, Heading, Text) so apps can boot quickly, but real apps will replace most of them with branded equivalents.
  • i18n. The host app handles locale via Gettext.put_locale/2 before Athanor renders.
  • Asset management. No built-in image picker — register your own via a :custom field type.

Documentation

Full API documentation lives on Hexdocs.

Why "Athanor"?

The athanor was an alchemist's slow-burning furnace, used for transmutations that needed a constant, even heat over long periods. Page builders feel a lot like that.

License

MIT © Zarar Siddiqi