One validated edit request — the only thing allowed to reach a
MishkaCmsCore.Runtime.PageBuilder.Tree combinator.
Every handle_event in the editor turns its raw params into an Op with build/1 before the
session touches the tree (ADR-13). That is what makes the server the authority rather than a
transcription service: a tampered payload fails validation here, and a plugin that wants to veto
or rewrite an edit gets a typed struct on the builder:node:before_* hook instead of a bag of
strings.
Why one struct and not twelve
ADR-13 sketches a struct per combinator. One struct with a validated kind and a per-kind
required-field check gives the same guarantee — nothing unvalidated reaches a combinator — with
one module to read and one hook payload shape for plugins to match on. It follows the house
precedent (MishkaCmsCore.Dashboard.Widget is likewise one struct with a type), and pattern
matching on %Op{kind: :move} reads the same as matching a Move struct would.
Insertion is always "parse this template"
Dropping a palette component, inserting an example variant and pasting the clipboard are the same
operation: take HEEx, parse it, splice the subtree in. :insert therefore carries a template
string rather than a pre-built node, which means an example keeps its attributes and children (the
React editor used only ast[0][0] and dropped both) and a pasted subtree is validated by the same
parser as everything else.
Summary
Functions
Applies a validated op to a tree.
The style buckets an override may be recorded in — every width crossed with every state.
Builds an op from raw client params.
A sample %MishkaCmsCore.Runtime.PageBuilder.Op{} populated from defaults + type-based fallbacks.
Every op kind the editor can issue.
A short label for the history sheet and the plugin payload — "Moved element", "Set class", …
Whether an op changes the document (and so belongs in the undo history) — every kind currently does, but the editor also routes selection and viewport changes through the same funnel, and those must not consume a history slot.
Types
@type t() :: %MishkaCmsCore.Runtime.PageBuilder.Op{ bucket: atom() | nil, index: integer() | nil, kind: atom(), name: String.t() | nil, node_id: String.t() | nil, node_ids: list() | nil, parent_id: String.t() | nil, prop: String.t() | nil, tag: String.t() | nil, template: String.t() | nil, text: String.t() | nil, value: String.t() | nil, value_kind: atom() | nil }
Functions
@spec apply_to(t(), MishkaCmsCore.Runtime.PageBuilder.Tree.t()) :: {:ok, MishkaCmsCore.Runtime.PageBuilder.Tree.t()} | {:ok, MishkaCmsCore.Runtime.PageBuilder.Tree.t(), String.t()} | {:error, term()}
Applies a validated op to a tree.
Returns {:ok, tree}, {:ok, tree, node_id} for the ops that produce a node the caller wants to
select afterwards, or {:error, reason}. Containment is not checked here — the session does
that, so a plugin rewriting an op on the before_* hook cannot accidentally bypass it.
@spec buckets() :: [atom()]
The style buckets an override may be recorded in — every width crossed with every state.
Builds an op from raw client params.
Accepts string or atom keys, coerces the handful of fields the wire sends as strings (index,
kind, bucket, value_kind) and then enforces the fields that kind actually needs — a :move
without a node_id, or a :set_style naming a bucket that does not exist, is refused here rather
than halfway through a combinator.
A sample %MishkaCmsCore.Runtime.PageBuilder.Op{} populated from defaults + type-based fallbacks.
@spec kinds() :: [atom()]
Every op kind the editor can issue.
A short label for the history sheet and the plugin payload — "Moved element", "Set class", …
Whether an op changes the document (and so belongs in the undo history) — every kind currently does, but the editor also routes selection and viewport changes through the same funnel, and those must not consume a history slot.