# `MishkaCmsCore.Runtime.PageBuilder.Tree`
[🔗](https://github.com/mishka-group/mishka_cms_core/blob/v0.0.1/lib/mishka_cms_core/runtime/page_builder/tree.ex#L5)

The editable model of a template: a flat, id-keyed map of `MishkaCmsCore.Runtime.PageBuilder.Tree.Node`
structs plus the list of root ids.

A tree is produced by `parse/1` from a HEEx string and turned back into HEEx by
`MishkaCmsCore.Runtime.PageBuilder.Tree.Serializer.serialize/1`. Everything in between — insert,
move, delete, duplicate, attribute and text edits — is a pure function here, which is what makes
the editor testable without a browser and what lets the server be the authority on every mutation
a client asks for.

## Why flat, and why ids

Nesting the structs would mean every edit rebuilds the spine down to the touched node and every
lookup walks from a root. A map keyed by id makes a lookup, a parent hop and a subtree extraction
all direct, and gives every node a stable handle for `phx-value-id`. The parser is unchanged and
still lossless (`MishkaCmsCore.Runtime.PageBuilder.LiveView.HtmlTokenizer`); this module only
normalizes its tuples into one shape.

## Identity is per-parse, not persistent

Ids are minted by `parse/1` and are stable for the life of an editing session, across every move
and re-render. They are **not** written to the template, so re-parsing the same source yields
different ids. Session state (selection, locks, hidden nodes, undo snapshots) may key on them
freely; anything that has to outlive a parse — durable per-node comments, for instance — needs its
own anchoring scheme and cannot simply store an id.

## Whitespace is part of the model

Whitespace-only text nodes are kept rather than dropped: `<b>a</b> <i>b</i>` and
`<b>a</b><i>b</i>` render differently, so discarding the run between two inline elements silently
changes the page. They carry `meta.whitespace?: true` so the canvas and the layers panel can hide
them without the serializer losing them.

## Error handling

`parse/1` is the one place in the editor that rescues. `EEx.tokenize/1` and the LiveView tokenizer
signal a malformed template by raising, there is no non-raising variant, and an admin pasting
broken HEEx into the code drawer must get an inline `{:error, %{line:, column:, message:}}`
instead of a 500. Every other function in this module is total.

# `error`

```elixir
@type error() :: {:error, atom() | map()}
```

# `parent`

```elixir
@type parent() :: String.t() | :root
```

# `t`

```elixir
@type t() :: %MishkaCmsCore.Runtime.PageBuilder.Tree{
  nodes: %{
    required(String.t()) =&gt; MishkaCmsCore.Runtime.PageBuilder.Tree.Node.t()
  },
  roots: [String.t()]
}
```

# `address`

```elixir
@spec address(t(), String.t() | nil) :: String.t() | nil
```

The document-order ADDRESS of `id` — sibling indices from the root, joined with dots (`"0.2.1"`).

An id is minted by `Node.generate_id/0` and is therefore meaningful only inside the parse that
minted it: two people with the same document open have two id spaces over identical structure.
This is derived from the shape of the document alone, so both of them compute the same string for
the same element — which is what anything outliving a parse has to anchor on. A review note is
exactly that.

Whitespace-only text nodes are counted, because the model keeps them and skipping them here would
put this and `at_address/2` out of step with each other. That makes this address exact but not
stable across a re-format — see `content_address/2` for the one that is.

# `ancestors`

```elixir
@spec ancestors(t(), String.t()) :: [MishkaCmsCore.Runtime.PageBuilder.Tree.Node.t()]
```

Ancestor nodes of `id`, nearest parent first.

# `at_address`

```elixir
@spec at_address(t(), String.t() | nil) :: String.t() | nil
```

The id sitting at a document-order `address`, or `nil` when nothing is there any more.

The inverse of `address/2`, and deliberately unforgiving: a segment that runs off the end of a
child list answers `nil` rather than clamping to the nearest sibling. A caller wanting the nearest
thing can ask for it; one that is silently handed a different element cannot tell.

# `at_content_address`

```elixir
@spec at_content_address(t(), String.t() | nil) :: String.t() | nil
```

The id sitting at a `content_address/2`, or `nil` when nothing is there any more.

As unforgiving as `at_address/2`: a segment past the end of a child list answers `nil` rather than
clamping to the nearest sibling.

# `blank?`

```elixir
@spec blank?(t(), parent()) :: boolean()
```

Whether `id` renders nothing yet — no children at all, or nothing but the indentation.

EMPTY THE WAY AN AUTHOR MEANS IT. Taking the last thing out of a container leaves behind the
whitespace that used to sit around it, so `children` reads `[whitespace, whitespace]` rather than
`[]`, and the box that had a floor a moment ago collapses back to nothing the instant it is
emptied — the one moment it most needs to count as a hole to put something in.

# `child_ids`

```elixir
@spec child_ids(t(), parent()) :: [String.t()]
```

The ids of `id`'s children, or the roots for `:root`.

# `children`

```elixir
@spec children(t(), parent()) :: [MishkaCmsCore.Runtime.PageBuilder.Tree.Node.t()]
```

The child nodes of `id`, or the root nodes for `:root`, in document order.

# `class_with`

```elixir
@spec class_with(MishkaCmsCore.Runtime.PageBuilder.Tree.Node.t(), [String.t()]) ::
  {:string | :expr, String.t()}
```

The `class` value an element would carry with `classes` on it, as the `{kind, source}` pair
`set_attr/4` takes.

THE PANELS WRITE CLASSES TOO, and a class row rebuilt as one joined string is how the reusable
classes list and the Behavior switches used to write `class={[@variant, "pb-…"] brand-card}` — the
expression's own source turned into text beside a class, which is not Elixir and not a page that
compiles. They ask for the value here instead, so an expression is spliced into exactly as a
minted scope is.

A class the element already wears is not added twice.

# `class_without`

```elixir
@spec class_without(MishkaCmsCore.Runtime.PageBuilder.Tree.Node.t(), [String.t()]) ::
  {:string | :expr, String.t()}
```

The same, with `classes` taken off — `{:string, ""}` when nothing of the class is left, which is
the editor's spelling of "remove the attribute".

# `classes`

```elixir
@spec classes(MishkaCmsCore.Runtime.PageBuilder.Tree.Node.t()) :: [String.t()]
```

The literal classes an element wears, in the order they were written.

A CLASS HELD IN A LIST EXPRESSION IS ONE OF THEM. `class={[@variant, "pb-0167f46c"]}` is the shape
a scope is minted into when the author's class is an expression — the only shape that can name the
element without writing the literal text `@variant` over the value it works out at render time —
and reading the string form alone answered `nil` over a name that was right there: the element's
own rule fell out of `MishkaCmsCore.Runtime.PageBuilder.ElementCss.live_rules/2` for the next save
to prune away, and `ensure_scope/2` minted a second name beside the first on every visit to the
CSS box.

So this is what every reader of an element's classes asks, in the panels as much as in here. What
the author works out at render time is not a class and is not answered.

# `content_address`

```elixir
@spec content_address(t(), String.t() | nil) :: String.t() | nil
```

The address of `id` counting only the children that carry content — the one that survives a
re-format.

FORMATTING IS NOT STRUCTURE, but the model cannot tell them apart: `Serializer.to_heex/1` indents,
and parsing that back turns every indent into a text node. A document written flat and read once
through the serializer therefore gains a whitespace sibling before every element, and every
`address/2` shifts by one — measured on `<div><h1>…` as `"0.0"` becoming `"0.1"`, then staying put
forever after.

So anything that has to find the same element on the far side of a HEEx round trip — restoring a
snapshot is exactly that — anchors on this instead, where the indentation is not counted and the
address does not move when the formatter runs.

`nil` for a node that is itself only whitespace: formatting has no address of its own.

# `delete`

```elixir
@spec delete(t(), String.t()) :: {:ok, t()} | error()
```

Deletes `id` and everything beneath it.

# `delete_attr`

```elixir
@spec delete_attr(t(), String.t(), String.t() | :root) :: {:ok, t()} | error()
```

Removes an attribute. An unset attribute serializes to nothing, restoring the default.

# `descendant?`

```elixir
@spec descendant?(t(), String.t(), String.t()) :: boolean()
```

Whether `maybe_descendant` sits anywhere beneath `id`.

# `descendant_ids`

```elixir
@spec descendant_ids(t(), String.t()) :: [String.t()]
```

Every descendant id of `id`, depth-first, excluding `id` itself.

# `drop_scope`

```elixir
@spec drop_scope(t(), String.t()) :: t()
```

Takes the scope class off, for an element whose custom CSS has been cleared.

# `duplicate`

```elixir
@spec duplicate(t(), String.t()) :: {:ok, t(), String.t()} | error()
```

Deep-copies `id` with fresh ids and inserts the copy immediately after the source — the v2
demo's semantics. Returns the new tree and the copy's root id.

# `ensure_scope`

```elixir
@spec ensure_scope(t(), String.t()) :: {t(), String.t()} | {t(), nil}
```

Gives the node a scope class if it has none, and returns it with the tree.

# `get`

```elixir
@spec get(t(), String.t() | nil) ::
  MishkaCmsCore.Runtime.PageBuilder.Tree.Node.t() | nil
```

Looks a node up by id.

# `index_of`

```elixir
@spec index_of(t(), String.t()) :: non_neg_integer() | nil
```

The index of `id` among its siblings, or `nil` when it is not in the tree.

# `insert`

```elixir
@spec insert(
  t(),
  MishkaCmsCore.Runtime.PageBuilder.Tree.Node.t() | t(),
  parent(),
  non_neg_integer()
) ::
  {:ok, t()} | error()
```

Inserts a node or a whole subtree under `parent` at `index`.

`index` is clamped into the sibling list, so an out-of-range value appends rather than failing —
a drop past the last child is a legitimate gesture, not an error. Fails when the parent is missing
or cannot hold children.

# `move`

```elixir
@spec move(t(), String.t(), parent(), non_neg_integer()) :: {:ok, t()} | error()
```

Moves `id` under `new_parent` at `index`.

`index` is interpreted against the sibling list **with the moving node already removed**, which is
the contract the React `moveElementInList` tests pinned down: a same-parent reorder from position
2 to position 4 passes `4` and lands at `4`, with no caller-side off-by-one. Refuses to move a
node into itself or into one of its own descendants.

# `new`

```elixir
@spec new() :: t()
```

An empty tree — what a brand-new page starts from.

# `parse`

```elixir
@spec parse(String.t() | nil) :: {:ok, t()} | error()
```

Parses a HEEx template into a tree.

Returns `{:error, %{line:, column:, message:}}` for a template the tokenizer cannot read; the
editor surfaces that inline rather than failing the mount.

# `parse!`

```elixir
@spec parse!(String.t() | nil) :: t()
```

Parses a template, raising on a malformed one. For fixtures and tests, not request paths.

# `path`

```elixir
@spec path(t(), String.t()) :: [MishkaCmsCore.Runtime.PageBuilder.Tree.Node.t()]
```

The selection path from the outermost ancestor down to `id` — what the canvas crumb bar renders.
The React demo's crumb bar was static markup; this one is derived.

# `refresh_ids`

```elixir
@spec refresh_ids(t()) :: t()
```

Re-mints every id in a tree, keeping the structure. Used by duplicate and by paste, so a pasted
subtree never collides with the ids already in the document.

# `reset_styles`

```elixir
@spec reset_styles(t(), String.t(), atom()) :: {:ok, t()} | error()
```

Clears every override recorded in one bucket — the inspector's per-bucket Reset.

# `rewrite_site`

```elixir
@spec rewrite_site(t(), String.t() | nil, [map()]) :: t()
```

Binds every runtime component's `site` attribute to the scope that component actually lives in.

The compiled module's name is hashed from this attribute, so it has to be right: a TENANT component
belongs to `site`, and a GLOBAL one belongs to `"Global"` no matter whose page it is placed on.
Rewriting both to the page's site — which an earlier version did, on the theory that the kit's
hardcoded `site="Global"` was always wrong — named modules nobody had built, and every global
component inserted from an example came out as the runtime's red "failed to render".

`components` is the palette, whose entries know which they are. A component the palette has never
heard of is left exactly as authored rather than guessed at, and so is a node with no `site` at all.

# `same_shape_at?`

```elixir
@spec same_shape_at?(t(), t(), String.t() | nil) :: boolean()
```

Whether `address` names the same element in `before` and in `now`.

AN INDEX ONLY MEANS SOMETHING WHEN BOTH DOCUMENTS AGREE ABOUT THE SHAPE. Delete the first of three
`<li>`s and everything below slides up one, so the address that named the second now names the
third — in bounds, same kind, same tag, and completely wrong. A position cannot tell you it has
been handed a stranger, so the question has to be asked of the documents instead.

The answer is yes when every step down the path has the same run of children, BY NAME, in both.
Names and not contents on purpose: an element whose text, styles or attributes were edited is still
the same element, and undoing exactly that edit is what a restore is for.

So anything that changed only what is inside elements carries across, and anything that changed the
document's shape does not — which is precisely when a position stops meaning anything.

# `scope`

```elixir
@spec scope(t(), String.t()) :: String.t() | nil
```

The class that names this element for its own CSS, or `nil` when it has none yet.

A minted class rather than an id: it goes in the `class` attribute like everything else, so it
survives copy, duplicate and paste with the node, and `MishkaCmsCore.Runtime.PageBuilder.ElementCss`
can key a rule on it without the editor needing to store anything that a re-parse would lose.

# `set_attr`

```elixir
@spec set_attr(t(), String.t(), String.t() | :root, term()) :: {:ok, t()} | error()
```

Sets an attribute, preserving declaration order and replacing an existing one in place.

# `set_style`

```elixir
@spec set_style(t(), String.t(), atom(), String.t(), String.t() | nil) ::
  {:ok, t()} | error()
```

Records one responsive/pseudo style override on a node.

Overrides live in `meta.styles` as `%{bucket => %{prop => value}}` where a bucket is `:base`,
`:tablet`, `:mobile`, `:hover`, `:focus` or `:active`. They are compiled into Tailwind utilities
on save (ADR-8) rather than written as inline styles, so the runtime Tailwind pass — which only
sees server-rendered classes — can generate the rules.

# `set_text`

```elixir
@spec set_text(t(), String.t(), String.t()) :: {:ok, t()} | error()
```

Replaces a node's text content.

# `size`

```elixir
@spec size(t()) :: non_neg_integer()
```

How many nodes the tree holds.

# `subtree`

```elixir
@spec subtree(t(), String.t()) :: t()
```

Extracts `id` and its descendants as a standalone tree — the shape the clipboard stores and
`insert/4` accepts back.

# `unshare_scopes`

```elixir
@spec unshare_scopes(t()) :: t()
```

Leaves each element wearing one minted class, for markup that arrives wearing two.

Paste styles used to carry the SOURCE element's minted class onto the target, so every document
saved before that was fixed holds elements wearing somebody else's name ahead of their own — and
`scope/2` answers with the first, which is what points the target's CSS box, its Apply and every
interaction aimed at it at the source's rule, and what takes the target's own rule out of
`MishkaCmsCore.Runtime.PageBuilder.ElementCss.live_rules/2` for the next save to prune away.

The name it keeps is the one no other element in the document wears: a pasted name is on at least
two elements and an element's own is on one, which is the whole of what that paste left behind.
When that singles out none — two elements duplicated after such a paste wear the same pair — the
markup no longer says which name was whose, and nothing is taken off.

Run when a document is OPENED rather than on every parse: the runtime renders this markup too, and
a name silently different from the one the stylesheet was compiled against is a live page losing
its styling. In the editor the next save writes the answer back.

# `unwrap`

```elixir
@spec unwrap(t(), String.t()) :: {:ok, t()} | error()
```

Removes `id`, promoting its children into its place.

# `walk`

```elixir
@spec walk(t()) :: [MishkaCmsCore.Runtime.PageBuilder.Tree.Node.t()]
```

Every node, depth-first in document order.

# `wrap`

```elixir
@spec wrap(t(), String.t(), MishkaCmsCore.Runtime.PageBuilder.Tree.Node.t()) ::
  {:ok, t()} | error()
```

Wraps `id` in `wrapper`, which takes the node's place among its siblings.

# `wrap_many`

```elixir
@spec wrap_many(t(), [String.t()], MishkaCmsCore.Runtime.PageBuilder.Tree.Node.t()) ::
  {:ok, t()} | error()
```

Wraps several siblings in one container, which takes the place of the first of them.

SIBLINGS ONLY. Three cards in a row have an obvious container; a heading here and a button two
branches away do not, and inventing one would move them somewhere neither of them was. The
document order of the selection is kept, not the order they were clicked in.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
