MishkaCmsCore.Runtime.Authoring.Containment (Mishka CMS Core v0.0.5-alpha.4)

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.
  • Raw text elements (textarea, title, script, style) take text and expressions only. A browser reads an element written inside one as text and shows its markup.
  • Phrasing-only elements (span, p, h1–h6, button, pre, …) take text and other phrasing content, never a block element, and phrasing content is the HTML standard's list of it: a <br>, an <img>, an <input>, an <svg>, a <select>, a <del>, a <button>, a <label>, a link and a custom element (a name with a hyphen) belong in a <p>, a <div>, a <ul>, a <section>, a form and a kit card do not. 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. The CMS's own function components have no markup in the catalog and are the element they render: a <.form> is a <form>, an <.input> the <div> around its control, a <.link> an <a>, so neither a form nor a field goes inside a <p>. A kit component whose markup opens with another component is the element that one renders: a kit form wrapper opening with <.form> is a <form>. 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. Markup that was typed rather than dropped is asked with misplaced/2, which reuses three of these rules for a whole document.

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), or for one of the CMS's own function components the element it renders ("form" for <.form>). nil when that cannot be read.

Whether this node may hold children at all, asked without a node being dragged.

The first node of tree, in document order, that sits where the placement rules would have refused it, as {node, message}; nil when every node is where they allow it.

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), or for one of the CMS's own function components the element it renders ("form" for <.form>). 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.

misplaced(tree, opts \\ [])

The first node of tree, in document order, that sits where the placement rules would have refused it, as {node, message}; nil when every node is where they allow it.

This is can_drop?/4 asked of a document that is already written, which is what an editor has to answer for markup that was typed or pasted instead of dropped. It asks three of its rules, in the words a refused drop gives:

  • anything but phrasing content inside a <p>, a heading, a <span> or another element whose content model is phrasing content, as the standard lists it (@phrasing): a <div>, a <ul>, a kit card or a form there is refused, a <br>, an <img>, an <input> or a custom element is not. The browser closes a <p> in front of a block, so the page it draws is not the tree that was written;
  • a form inside a form, whose inner one a browser drops;
  • an element, a component or a comment inside a text-only element.

button and label also take phrasing content in the standard, but the Chelekom kit's own tabs, accordion, combobox, radio card, checkbox card and toggle field put a <div> in one, and summary, legend, dt and figcaption take more than phrasing content. A document is held in those only to a form or a text block (a paragraph, a heading, a <pre>...); a drop keeps them to phrasing content, as it does every host.

The rest of what can_drop?/4 asks, such as a slot to put content in, the only children of a list or a landmark inside a landmark, stays a rule about where something may be put.

Every node is asked about on its own, so a form inside a form is reported at the inner form and not at the <div> that holds it. opts takes :components, as can_drop?/4 does.

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.