MishkaCmsCore.Runtime.Authoring.Containment (Mishka CMS Core v0.0.3)

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, 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 from containment.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 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. 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

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.

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.

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.Authoring.Fields.roles/2 builds, computed once per drag by accepts_map/3 and 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 by accepts_map/3, and read off :components as needed for a single drop.
  • :fragment? — true when 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.

can_hold?(child, tag, opts \\ [])

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

content_element(node, opts \\ [])

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

element(node, opts \\ [])

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.

holds_children?(node, opts \\ [])

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

parent_restricted?(tag)

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

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.

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.

table_homes(tag)

@spec table_homes(String.t() | nil) :: [String.t()] | nil

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.

transparent?(tag)

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

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

void_tags()

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

Tags that can never hold children.