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

Copy Markdown View Source

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 from containment.js.
  • Structural pairs the spec fixes exactly: a table section takes only tr, a tr takes only td/th, a list takes only li, a dl takes dt/dd/div, a select takes option/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

accepts_children?(tag)

@spec accepts_children?(String.t() | nil) :: boolean()

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.

accepts_map(tree, dragged, opts \\ [])

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.

can_drop?(tree, dragged, target, opts \\ [])

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 with name and slots), used to decide whether a component declares somewhere to put a child and what part it plays in a form.
  • :dragged_tree — the tree dragged belongs 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 memo MishkaCmsCore.Runtime.PageBuilder.Fields.roles/2 builds, computed once per drag by accepts_map/3 and rebuilt here when a caller asks about a single drop.

Returns :ok or {:error, message} with a sentence meant for the person dragging.

can_hold?(child, tag)

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

holds_children?(node, opts \\ [])

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

parent_restricted?(tag)

@spec parent_restricted?(String.t() | nil) :: boolean()

Whether a tag may only appear under a specific parent — see parent_restricted_tags/0.

parent_restricted_tags()

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

phrasing_only?(tag)

@spec phrasing_only?(String.t() | nil) :: boolean()

Whether a tag holds phrasing content only, so no block element may be dropped into it.

phrasing_only_tags()

@spec phrasing_only_tags() :: [String.t()]

Tags that hold phrasing content only — no block child may be dropped into them.

void_tags()

@spec void_tags() :: [String.t()]

Tags that can never hold children.