A component in the builder's palette — from the database, or contributed by an extension.
The two paths, one shape
Most palette entries are Component rows: an extension that ships template-shaped components
(a post card, a hero) installs them as ordinary rows and everything downstream — compile,
resolution, palette, inspector, examples — already works with no editor changes at all.
Some components cannot be a stored HEEx template: "Latest posts", streaming live data, is a real
LiveView component in the extension's own code. Those register on the builder:components hook as
descriptors carrying a render ref, and are inserted as a module call rather than a
<.component component_name=…> wrapper.
Both arrive here as the same struct, so the palette, the search, the categories and the property panel are written once.
render is never a closure
A descriptor travels through a MishkaInstaller hook payload, which is plain-map only — the same
rule MishkaCmsCore.Dashboard.Widget follows. render is {module, function}, and the template
the editor emits for it is a fully-qualified module call.
Serialization is whitelisted
A module call in a template is arbitrary code at compile time. template/2 will only emit a call
for a module that a registered descriptor actually named, so the editor cannot be talked into
emitting a call to anything else — defence in depth, since templates are admin-authored anyway.
Summary
Functions
Builds a descriptor, returning {:error, _} instead of raising — for untrusted input.
Whether this component renders its own content closed — a dialog, a drawer, a collapsible.
A sample %MishkaCmsCore.Builder.PaletteEntry{} populated from defaults + type-based fallbacks.
Whether this entry renders through an extension module rather than a stored template.
Builds a descriptor, raising on an invalid one.
The open/close commands this component declares on its own root element.
Reads an optional field off an entry, whichever shape it arrived in.
Whether template only calls modules in whitelist. A template naming an unregistered module is
refused rather than compiled.
Every assign a component's own markup and helpers read.
The required attributes a component cannot render without, written as HEEx attribute text.
The required slots a component cannot render without, written as HEEx slot text.
Whether this component styles the children an author puts inside it.
The HEEx this entry inserts.
The set of modules the editor is permitted to emit a call to — every module a registered descriptor named, and nothing else.
Types
@type t() :: %MishkaCmsCore.Builder.PaletteEntry{ attrs: list() | nil, description: String.t() | nil, doc_url: String.t() | nil, examples: list() | nil, features: map() | nil, form: map() | nil, furnishing: map() | nil, global?: boolean() | nil, helpers: list() | nil, icon: String.t() | nil, key: String.t(), label: String.t() | nil, name: String.t(), priority: integer() | nil, reads: any() | nil, render: any() | nil, slots: list() | nil, stateful?: boolean() | nil, template: String.t() | nil, type: String.t() | nil }
Functions
Builds a descriptor, returning {:error, _} instead of raising — for untrusted input.
Whether this component renders its own content closed — a dialog, a drawer, a collapsible.
A DECLARATION FIRST, a guess only when there is none. opens/1 reads what the component itself
says; the markup scan below is what answers for the hundred and thirty-six that were written
before there was anything to say it with. Same contract as opts["editor"] on an attribute and
extra.furnishing on a row: what the author declared wins, and inference is the fallback.
The guess is deliberately a generous one: the cost of a wrong yes is a glyph on a row that did not need it, and the cost of a wrong no is content the author cannot see at all.
A sample %MishkaCmsCore.Builder.PaletteEntry{} populated from defaults + type-based fallbacks.
Whether this entry renders through an extension module rather than a stored template.
Builds a descriptor, raising on an invalid one.
Extensions declare these in module attributes, so a malformed descriptor fails the build rather
than the page — the Dashboard.Widget precedent.
The open/close commands this component declares on its own root element.
Why a component has to say it
Because "open it" is not one thing. A dialog is display:none on the root AND display:none again
on the panel inside it, so revealing the root paints the backdrop over a page and shows no dialog.
A drawer is fully displayed and held off-screen by -translate-x-full. A dropdown is opened by a
JavaScript hook that removes a boolean attribute and adds three classes. A navbar wants one class
toggled on the very element the editor is already pointing at. One generic JS.show is right for
exactly one of those shapes and silently does nothing for the rest — which is what it did.
The component is the only thing that knows which of those it is, so it is the thing that says:
<div id={@id} data-pb-open={show_modal(@id)} data-pb-close={hide_modal(@id)} class={[…]}>The value is an ordinary Phoenix.LiveView.JS command — the same shape as the data-cancel the
kit's own dialogs already carry, and chelekom-mega-menu already ships this exact pattern under
its own name (phx-open-mega, run with JS.exec/2 from its trigger). A button aimed at the
component compiles to JS.exec("data-pb-open", to: ".pb-xxxxxxxx"), so what runs is the
component's own sequence — every transition, the body scroll lock, the focus trap — rather than
the editor's guess at it.
Declaring nothing still works: a component that renders closed with no declaration keeps the
layers glyph on inference, and a click aimed at it keeps compiling to the plain JS verb.
Returns %{verb => attribute} for the verbs actually declared, %{} for none.
Reads an optional field off an entry, whichever shape it arrived in.
THE PALETTE HOLDS TWO SHAPES AND ALWAYS WILL. The core builds a plain map with everything it
learned in the read that filled the palette; a plugin contributes a %PaletteEntry{}, which is
the documented way to add one and carries only the fields this struct declares. Asking a struct
for entry[:has_examples?] raises — structs have no Access — so a single contributed component
took the whole palette down, and with it the editor.
Answers nil for a field the entry does not carry, which for every optional field is the honest
answer: a contributed component has no examples to browse until it says it does.
Whether template only calls modules in whitelist. A template naming an unregistered module is
refused rather than compiled.
Every assign a component's own markup and helpers read.
Public because the palette builds its entries as plain maps rather than through new!/1, and this
is what lets it carry the answer instead of the source: helpers is 87% of a palette's weight and
the only runtime questions asked of it are this scan and Inspector.features/1, both of which are
computed once when the palette is read.
The required attributes a component cannot render without, written as HEEx attribute text.
A REQUIRED attribute with no default is an assign the component's own template reads and nothing
supplies — @source on chelekom-video, @id on chelekom-banner — and reading a missing assign
raises KeyError, which takes the whole canvas LiveView down with it the instant the component is
dropped. Seeding those attributes is what makes dropping one survivable; the value is a placeholder
the author then edits in the inspector, and every other attribute is left alone so the component's
own defaults keep applying.
style is :blank for PLACEMENT and :preview for the wizard. Placement leaves a required value
empty on purpose — the inspector marks it "Required" and the save refuses until the author fills
it, which is the guard against publishing an incomplete page. A preview has no author to wait for,
so it uses a real placeholder: an empty src renders as "needs src" rather than as an image.
Public because the palette is not the only place that has to produce a RENDERABLE component:
MishkaCmsCore.Runtime.PageBuilder.Wizard composes one too, and without the same seeding its
preview showed "needs id" instead of the component, which made every option in the wizard look
broken.
skip names attributes the caller is writing itself, so the two cannot both emit one.
components is the palette, which decides whether a component calling another owns a form.
The required slots a component cannot render without, written as HEEx slot text.
A REQUIRED SLOT is the same trap wearing different clothes — see seeded/2. Public for the same
reason.
Whether this component styles the children an author puts inside it.
A component that writes [&>*]:rounded-none means the element immediately inside it, and the
editor's own wrapper around each of those children is what the rule lands on instead. That is why
a chelekom-button-group drew three separately rounded buttons on the canvas and one joined
control on the page: the CSS was never reaching the buttons at all.
Read off the TEMPLATE AND THE HELPER BODIES, once, where the source is still in hand — see the
note on reads/1 in palette_entry/1's caller for why it cannot be asked again later.
A bare string is asked the same question: <div class="[&>*]:rounded-none"> is an author making
the same promise by hand, and the canvas has to keep it the same way.
The HEEx this entry inserts.
A database component becomes the wrapper form every stored template already uses; a descriptor
with a render ref becomes a module call.
A TENANT component is bound to the page's own site; a GLOBAL one is bound to "Global" whatever
page it lands on. That distinction is not cosmetic: the compiled module's name is hashed from this
attribute, so binding a global component to the page's site named a module nobody ever built and
the canvas showed the runtime's red "failed to render" the moment it was dropped.
components is the palette, which decides whether a component calling another owns a form.
The set of modules the editor is permitted to emit a call to — every module a registered descriptor named, and nothing else.