Pure-data tree operations over the page builder JSON shape.
See Athanor for the project boundary contract. This module accepts and
returns plain Elixir maps with string keys — no structs, no JSON encoding,
no Phoenix/Ecto/Amplify dependencies.
Shape
A tree is a map with two well-known keys:
%{
"metadata" => map(),
"content" => [node, ...]
}A node is a map:
%{
"id" => "uuid-string",
"type" => "component-type-string",
"props" => map()
}A node MAY declare children via node["props"]["zones"] as a map of
%{zone_name => [child_node, ...]}. Tree operations walk these zones
automatically — no registry or callback required. Unknown keys at any
level (top-level, props, metadata) are preserved on round-trip.
Summary
Functions
Locate a node by its id, searching the root content and recursively
through every props["zones"].
Normalize a decoded JSON value into the canonical tree shape, filling in
metadata and content defaults and preserving every other key untouched.
Insert node into the tree at the given target.
Swap the node identified by id with its previous (:up) or next
(:down) sibling inside the same containing list (root content or a
specific zone). Moving past a boundary (first/last) is a no-op.
Remove the node with the given id from anywhere in the tree.
Serialize a tree back to a JSON-encodable map.
Update the props of the node identified by id.
Walk every node in the tree, invoking fun.(node, acc) for each.
Functions
Locate a node by its id, searching the root content and recursively
through every props["zones"].
Returns {:ok, node} or :error when no node has the given id.
Examples
iex> tree = %{"metadata" => %{}, "content" => [
...> %{"id" => "a", "type" => "text", "props" => %{}}
...> ]}
iex> {:ok, node} = Athanor.Tree.find(tree, "a")
iex> node["type"]
"text"
iex> Athanor.Tree.find(%{"metadata" => %{}, "content" => []}, "missing")
:error
Normalize a decoded JSON value into the canonical tree shape, filling in
metadata and content defaults and preserving every other key untouched.
Accepts nil to mean "empty tree".
Examples
iex> Athanor.Tree.from_json(nil)
%{"metadata" => %{}, "content" => []}
iex> Athanor.Tree.from_json(%{"content" => []})
%{"metadata" => %{}, "content" => []}
iex> Athanor.Tree.from_json(%{"metadata" => %{"title" => "Hi"}})
%{"metadata" => %{"title" => "Hi"}, "content" => []}
Insert node into the tree at the given target.
Targets:
:root— into the top-levelcontentlist{parent_id, zone_name}— into a specific zone of a specific parent node
Options:
:at—:append(default),:prepend,{:index, n},{:after, sibling_id}
Returns {:ok, new_tree} or {:error, reason} where reason is one of
:parent_not_found, :zone_not_found, or :sibling_not_found.
Examples
iex> tree = %{"metadata" => %{}, "content" => [%{"id" => "a", "type" => "text", "props" => %{}}]}
iex> n = %{"id" => "b", "type" => "text", "props" => %{}}
iex> {:ok, t2} = Athanor.Tree.insert(tree, :root, n)
iex> Enum.map(t2["content"], & &1["id"])
["a", "b"]
Swap the node identified by id with its previous (:up) or next
(:down) sibling inside the same containing list (root content or a
specific zone). Moving past a boundary (first/last) is a no-op.
Returns {:ok, new_tree} or {:error, :not_found} if the id is absent.
Examples
iex> tree = %{"metadata" => %{}, "content" => [
...> %{"id" => "a", "type" => "text", "props" => %{}},
...> %{"id" => "b", "type" => "text", "props" => %{}}
...> ]}
iex> {:ok, t2} = Athanor.Tree.move(tree, "b", :up)
iex> Enum.map(t2["content"], & &1["id"])
["b", "a"]
Remove the node with the given id from anywhere in the tree.
Idempotent: removing an unknown id returns {:ok, tree} unchanged.
Examples
iex> tree = %{"metadata" => %{}, "content" => [%{"id" => "a", "type" => "text", "props" => %{}}]}
iex> {:ok, t2} = Athanor.Tree.remove(tree, "a")
iex> t2["content"]
[]
iex> tree = %{"metadata" => %{}, "content" => [%{"id" => "a", "type" => "text", "props" => %{}}]}
iex> {:ok, t2} = Athanor.Tree.remove(tree, "ghost")
iex> t2 == tree
true
Serialize a tree back to a JSON-encodable map.
Currently an identity function over the canonical shape — the tree is
already a plain map. Exists as a paired entry point so callers can
always pipe from_json |> ... |> to_json without thinking about
whether the intermediate ops left the shape decoded or encoded.
Examples
iex> Athanor.Tree.to_json(%{"metadata" => %{}, "content" => []})
%{"metadata" => %{}, "content" => []}
Update the props of the node identified by id.
The third argument may be either a map (shallowly merged into the
current props) or a function (current_props -> new_props).
Returns {:ok, new_tree} or {:error, :not_found} if the id is absent.
Examples
iex> tree = %{"metadata" => %{}, "content" => [
...> %{"id" => "a", "type" => "text", "props" => %{"text" => "old"}}
...> ]}
iex> {:ok, t2} = Athanor.Tree.update_props(tree, "a", %{"text" => "new"})
iex> {:ok, node} = Athanor.Tree.find(t2, "a")
iex> node["props"]["text"]
"new"
Walk every node in the tree, invoking fun.(node, acc) for each.
Visit order is pre-order: a parent node is visited before its children, and children are visited left-to-right within their containing list. Zone iteration order follows the underlying map's iteration order, which for Erlang maps is insertion-stable for small maps and undefined for large ones. In practice page builder zones are small.
Children are discovered via the convention node["props"]["zones"]
being a %{zone_name => [child_node, ...]} map. Nodes whose zones is
absent or not a map are treated as leaves.
Examples
iex> tree = %{"metadata" => %{}, "content" => [
...> %{"id" => "a", "type" => "text", "props" => %{}},
...> %{"id" => "b", "type" => "text", "props" => %{}}
...> ]}
iex> Athanor.Tree.walk(tree, [], fn n, acc -> [n["id"] | acc] end) |> Enum.reverse()
["a", "b"]