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,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 fromcontainment.js, with one correction: a paragraph, a heading and the other text blocks hold phrasing content without BEING it, so none of them goes inside another one. - A link is transparent, as HTML's
<a>is: it holds what its parent may hold. In a<div>or at the top of a page it takes a heading and a paragraph — a card that is one link — and in a<p>, a heading or a<span>it takes inline content only. The nearest ancestor that is not a link decides. Arriving, a link is a block when it holds one. - 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. A link and a button refuse one at any depth, which is HTML's rule for both; the other interactive elements are asked as the target only.
- 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 component is the element its own markup opens with for the phrasing and landmark
rules: a kit navbar whose markup is one
<nav>is a<nav>, a kit heading is a heading. A component whose markup opens with something else, or that has no markup to read, stays opaque and is let through as before. - A form may not go inside a form, and nothing HTML forbids at silently. What counts as a
form is
MishkaCmsCore.Runtime.Authoring.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. - Landmarks keep out of each other: a
<header>or a<footer>holds no header, footer or<main>, and a<main>goes in no article, aside, header, footer or nav — HTML's own rules. A<nav>holds no header, footer or main either, which is stricter than the spec and is what a page footer clicked in while the navbar was selected needs. Asked of every ancestor, like the form rule. - A table's own parts go only where a table holds them: a cell only in a row, a row only in
a table or one of its sections, a section, a caption or a column group only in a table — the
parents that list them as their only children, read the other way. A kit component is the
part its markup opens with, and a kit component holding one is the element its markup renders
its content in (a kit table's
<tbody>). Not at the top of a page either; a component's own markup may open with one (fragment?: true).
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.Authoring.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.
The element node's children land in, lowercased: a tag is itself, and a component is the element
its own markup renders its inner block in ("tbody" for a kit table, "tr" for a kit row).
The element node puts on the page, lowercased: a tag is itself, and a component is the one
element its own markup opens with ("td" for a kit cell). nil when that cannot be read.
Whether this node may hold children at all, asked without a node being dragged.
Whether a tag may only appear 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.
The elements a table part may sit directly inside — ["tr"] for a cell — or nil for a tag a
browser keeps wherever it is written.
Whether a tag holds what its parent may hold rather than a content model of its own: an <a>
takes a paragraph in a <div> and inline content only in a <p>.
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.Authoring.Tree.t(), MishkaCmsCore.Runtime.Authoring.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.
An editor asks for it at dragstart; the visual editor (mishka_cms_builder) also folds in its
session's locks. A refusal here is not the end of the gesture there: it places the node beside the
target instead.
@spec can_drop?( MishkaCmsCore.Runtime.Authoring.Tree.t(), MishkaCmsCore.Runtime.Authoring.Tree.Node.t() | map(), MishkaCmsCore.Runtime.Authoring.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.Authoring.Fields.roles/2builds, computed once per drag byaccepts_map/3and rebuilt here when a caller asks about a single drop.:outer_tags— the element each component's markup opens with, by component name; built once per drag byaccepts_map/3, and read off:componentsas needed for a single drop.:fragment?—truewhen the tree is a fragment placed somewhere else later (a component's own markup), so its top level may hold a table part. A page's or a layout's may not.
Returns :ok or {:error, message} with a sentence meant for the person dragging.
@spec can_hold?( MishkaCmsCore.Runtime.Authoring.Tree.Node.t() | map(), String.t(), keyword() ) :: :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.
One pair is not a property of the pair alone: a link holds what its parent holds. Pass the tree
child sits in as :tree, and a link put around it holds what child's parent holds, since the
wrapper takes child's place; a link that is child is read with its own children. Without it a
link takes anything, and a link arriving is inline.
Returns :ok or {:error, message}, in the same words a refused drop uses.
@spec content_element( MishkaCmsCore.Runtime.Authoring.Tree.Node.t() | term(), keyword() ) :: String.t() | nil
The element node's children land in, lowercased: a tag is itself, and a component is the element
its own markup renders its inner block in ("tbody" for a kit table, "tr" for a kit row).
nil for a slot, for a component whose markup cannot be read and for one that hands its content
on to another component. opts takes :components.
@spec element(MishkaCmsCore.Runtime.Authoring.Tree.Node.t() | map(), keyword()) :: String.t() | nil
The element node puts on the page, lowercased: a tag is itself, and a component is the one
element its own markup opens with ("td" for a kit cell). nil when that cannot be read.
node is a Tree.Node or a palette entry carrying :tag; opts takes :components.
@spec holds_children?( MishkaCmsCore.Runtime.Authoring.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 — <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.
The elements a table part may sit directly inside — ["tr"] for a cell — or nil for a tag a
browser keeps wherever it is written.
Whether a tag holds what its parent may hold rather than a content model of its own: an <a>
takes a paragraph in a <div> and inline content only in a <p>.
@spec void_tags() :: [String.t()]
Tags that can never hold children.