The one authority on "may this node go inside that one" — the HTML content model plus the component/slot rules, in Elixir, on the server.
The React editor spread this over four competing lists in three files and only ever consulted them
in the browser, so a tampered payload (or the structure tree, which used a different list than the
canvas) could build markup no parser would accept. Here the client gets a hint — an accepts
map precomputed per node for instant drag feedback — and the server re-checks every insert and
every move before it mutates anything. Client hints, server decides (ADR-6).
The rules
- Void elements (
br,img,input, …) take no children at all. - Phrasing-only elements (
span,a,p,h1–h6,button,pre, …) take text and other phrasing content, never a block element. This is deliberately stricter than the spec —<p>may legally hold some things this rejects — because the stricter rule is the one that matches what an author means to build. Ported as-is fromcontainment.js. - Structural pairs the spec fixes exactly: a
tablesection takes onlytr, atrtakes onlytd/th, a list takes onlyli, adltakesdt/dd/div, aselecttakesoption/optgroup. - Anchors may not contain another interactive element — nested interactive content is invalid and unusable with a keyboard.
- Components and slots accept children when their metadata declares a slot (or when they
already hold content, which means a previous author put something there legitimately). A
dotted tag the kit catalog has never heard of is asked of the compiled module instead, so the
CMS's own
<.form>and<.input>are containers here the same way they are on the page. - A form may not go inside a form — the one ancestor rule in this module, and the only
nesting HTML both forbids and fails at silently. What counts as a form is
MishkaCmsCore.Runtime.PageBuilder.Fields.form_node?/2, the same answer the insert funnel and the audit rail take, so a kit's own form wrapper is one here too. The thing being dragged is asked about with everything under it, because a Block opens with a band tag and carries its form as a child.
Every refusal carries a sentence written for the person dragging, which the canvas shows as the demo's danger toast near the drop point.
A field that lands with no form around it is not refused: see
MishkaCmsCore.Runtime.PageBuilder.PageForms.FormPlacement, which wraps it instead. Building a
form top-down is an ordinary gesture, so only the nesting HTML itself forbids is an error here.
Summary
Functions
Whether a bare HTML tag can hold children at all — false only for void elements.
Precomputes, for every node in the tree, whether dragged may land inside it — the hint map the
drag hook uses to paint drop targets without a round trip per hover. The server still re-checks
the drop it is actually asked to perform.
Whether dragged may be dropped into target.
Whether child may sit inside a tag that does not exist yet.
Whether this node may hold children at all, asked without a node being dragged.
Whether a tag may only appear under a specific parent — see parent_restricted_tags/0.
Tags the content model only permits under a specific parent — <td> outside a <tr> is not a
cell, it is foster-parented out of the table by the parser.
Whether a tag holds phrasing content only, so no block element may be dropped into it.
Tags that hold phrasing content only — no block child may be dropped into them.
Tags that can never hold children.
Functions
Whether a bare HTML tag can hold children at all — false only for void elements.
This is deliberately narrower than the React canAcceptChild it replaces. That one also answered
false for every phrasing-only tag, and its single caller used the answer as "accepts any child",
so the editor refused to drop even a word of text into a <span> or an <a>. Whether a block
child is allowed is a separate question, asked per-pair by can_drop?/4.
Components and slots answer true; whether they really have somewhere to put a child depends on
their slot metadata, which can_drop?/4 checks.
@spec accepts_map( MishkaCmsCore.Runtime.PageBuilder.Tree.t(), MishkaCmsCore.Runtime.PageBuilder.Tree.Node.t() | map(), keyword() ) :: %{required(String.t()) => boolean()}
Precomputes, for every node in the tree, whether dragged may land inside it — the hint map the
drag hook uses to paint drop targets without a round trip per hover. The server still re-checks
the drop it is actually asked to perform.
Reached through MishkaCmsCore.Runtime.PageBuilder.EditorSession.drop_hints/2, which is what the
editor calls at dragstart and which also folds in the session's locks. A refusal here is not the
end of the gesture: EditorSession.apply_drop/2 places the node beside the target instead.
@spec can_drop?( MishkaCmsCore.Runtime.PageBuilder.Tree.t(), MishkaCmsCore.Runtime.PageBuilder.Tree.Node.t() | map(), MishkaCmsCore.Runtime.PageBuilder.Tree.parent(), keyword() ) :: :ok | {:error, String.t()}
Whether dragged may be dropped into target.
dragged is a Tree.Node, or a palette entry — any map carrying :tag (what it will become) and
optionally :slots. target is a node id or :root.
Options:
:components— the palette metadata list (maps withnameandslots), used to decide whether a component declares somewhere to put a child and what part it plays in a form.:dragged_tree— the treedraggedbelongs to, which for an insert is the parsed template rather than the document. Without it a Block's own<.form>is invisible here, because a block opens with a band tag and only its head node arrives.:roles— the memoMishkaCmsCore.Runtime.PageBuilder.Fields.roles/2builds, computed once per drag byaccepts_map/3and rebuilt here when a caller asks about a single drop.
Returns :ok or {:error, message} with a sentence meant for the person dragging.
@spec can_hold?(MishkaCmsCore.Runtime.PageBuilder.Tree.Node.t() | map(), String.t()) :: :ok | {:error, String.t()}
Whether child may sit inside a tag that does not exist yet.
A wrap mints its wrapper on the spot, so there is no node to point can_drop?/4 at and no place in
the tree to ask about — but the pairing still has to hold: a <span> will not take a <section>,
and an <img> will not take anything at all. Only the rules that are a property of the PAIR are
asked here; the ones that depend on where the wrapper lands (cycles, form nesting, an interactive
ancestor) are asked of the wrapper's destination instead, which is a real position in a real tree.
Returns :ok or {:error, message}, in the same words a refused drop uses.
@spec holds_children?( MishkaCmsCore.Runtime.PageBuilder.Tree.Node.t() | term(), keyword() ) :: boolean()
Whether this node may hold children at all, asked without a node being dragged.
The two rules can_drop?/4 applies to the TARGET alone — what HTML lets an element contain, and a
component needing somewhere to put content — and nothing about what is arriving. The layers rail
has to answer this while it renders a row, long before anything has been picked up, and answering
it a second way is how the rail came to refuse drops the server allows: drop_target?/2 required a
palette row, so the <.form> this editor wraps every loose field in — a component Phoenix declares
a slot for, and one no kit ships — was drawn as a row nothing could be dropped inside, and a field
aimed at the middle of it landed beside it instead.
A guess, not a verdict: it knows nothing about the node being moved, so can_drop?/4 still decides.
Whether a tag may only appear under a specific parent — see parent_restricted_tags/0.
@spec parent_restricted_tags() :: [String.t()]
Tags the content model only permits under a specific parent — <td> outside a <tr> is not a
cell, it is foster-parented out of the table by the parser.
The page builder asks this before it wraps a node in its own element: an editor wrapper around a
<td> lands where only a <td> may go, so the parser moves it out and the wrapper becomes an
orphan sitting above the table, taking the node's markers with it.
Whether a tag holds phrasing content only, so no block element may be dropped into it.
@spec phrasing_only_tags() :: [String.t()]
Tags that hold phrasing content only — no block child may be dropped into them.
@spec void_tags() :: [String.t()]
Tags that can never hold children.