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"}
]
endIf 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
end2. 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
endWire the route:
live "/admin/pages/:id/edit", MyAppWeb.PageEditorLive4. 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
endThat'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.
5. Wire the drag-and-drop hooks (optional but recommended)
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
| Module | Role |
|---|---|
Athanor.Tree | Pure-data manipulation of the content tree (insert, move, remove, find) |
Athanor.Component | Behaviour + use macro for declaring components |
Athanor.Registry | Runtime lookup of components by "type" string |
Athanor.Renderer | Dispatches each node to its component's render/3 |
Athanor.Ctx | Render/edit context (account_id, brand_id, edit_mode?, etc.) |
Athanor.Editor.Live | use macro that injects the LiveView |
Athanor.Editor | Function components (canvas, components_panel, config_panel, shell) for custom layouts |
Athanor.Fields | Auto-renders a component's fields/0 schema into form inputs |
Athanor.Field | Behaviour-style contract for custom field LiveComponents |
Athanor.AutoEditorForm | LiveComponent 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 optionalmin:/max::select— dropdown driven byoptions: [{label, value}, ...](or a function ofCtx):color— color picker with a Clear button:checkbox— boolean:custom— mounts your own LiveComponent (image picker, product selector, rich-text editor, anything) by passingmodule: 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
endOverride 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
endSame 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_mountchain 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/2before Athanor renders. - Asset management. No built-in image picker — register your own
via a
:customfield 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