Athanor.Tree (Athanor v0.1.0-beta.1)

Copy Markdown View Source

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

find(tree, id)

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

from_json(map)

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(tree, target, node, opts \\ [])

Insert node into the tree at the given target.

Targets:

  • :root — into the top-level content list
  • {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"]

move(tree, id, direction)

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(tree, id)

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

to_json(tree)

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_props(tree, id, props_or_fn)

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(tree, acc, fun)

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"]