MishkaCmsCore.Runtime.PageBuilder.Tree (Mishka CMS Core v0.0.1)

Copy Markdown View Source

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.

Summary

Functions

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

Ancestor nodes of id, nearest parent first.

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

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

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

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

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

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

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

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

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

Deletes id and everything beneath it.

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

Whether maybe_descendant sits anywhere beneath id.

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

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

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.

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

Looks a node up by id.

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

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

Moves id under new_parent at index.

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

Parses a HEEx template into a tree.

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

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.

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.

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

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

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

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

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

Records one responsive/pseudo style override on a node.

Replaces a node's text content.

How many nodes the tree holds.

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

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

Removes id, promoting its children into its place.

Every node, depth-first in document order.

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

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

Types

error()

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

parent()

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

t()

@type t() :: %MishkaCmsCore.Runtime.PageBuilder.Tree{
  nodes: %{
    required(String.t()) => MishkaCmsCore.Runtime.PageBuilder.Tree.Node.t()
  },
  roots: [String.t()]
}

Functions

address(tree, id)

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

Ancestor nodes of id, nearest parent first.

at_address(tree, address)

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

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

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

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

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

children(tree, id)

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

class_with(node, classes)

@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(node, classes)

@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(node)

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

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

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

Deletes id and everything beneath it.

delete_attr(tree, id, name)

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

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

Whether maybe_descendant sits anywhere beneath id.

descendant_ids(tree, id)

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

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

drop_scope(tree, id)

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

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

duplicate(tree, id)

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

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

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

Looks a node up by id.

index_of(tree, id)

@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(tree, node, parent, index)

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

@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()

@spec new() :: t()

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

parse(source)

@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!(source)

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

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

path(tree, id)

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

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

@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(tree, site, components \\ [])

@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?(before, now, address)

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

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

@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(tree, id, bucket, prop, value)

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

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

Replaces a node's text content.

size(tree)

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

How many nodes the tree holds.

subtree(tree, id)

@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(tree)

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

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

Removes id, promoting its children into its place.

walk(tree)

Every node, depth-first in document order.

wrap(tree, id, wrapper)

@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(tree, ids, wrapper)

@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.