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
Functions
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.
@spec ancestors(t(), String.t()) :: [MishkaCmsCore.Runtime.PageBuilder.Tree.Node.t()]
Ancestor nodes of id, nearest parent first.
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.
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.
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.
The ids of id's children, or the roots for :root.
@spec children(t(), parent()) :: [MishkaCmsCore.Runtime.PageBuilder.Tree.Node.t()]
The child nodes of id, or the root nodes for :root, in document order.
@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.
@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".
@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.
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.
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.
@spec get(t(), String.t() | nil) :: MishkaCmsCore.Runtime.PageBuilder.Tree.Node.t() | nil
Looks a node up by 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.
@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.
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.
@spec new() :: t()
An empty tree — what a brand-new page starts from.
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.
Parses a template, raising on a malformed one. For fixtures and tests, not request paths.
@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.
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.
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.
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.
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.
Sets an attribute, preserving declaration order and replacing an existing one in place.
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.
Replaces a node's text content.
@spec size(t()) :: non_neg_integer()
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.
Removes id, promoting its children into its place.
@spec walk(t()) :: [MishkaCmsCore.Runtime.PageBuilder.Tree.Node.t()]
Every node, depth-first in document order.
@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.
@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.