MishkaCmsCore.Builder.PaletteEntry (Mishka CMS Core v0.0.1)

Copy Markdown View Source

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

t()

@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

build(attrs)

@spec build(map()) :: {:ok, t()} | {:error, term()}

Builds a descriptor, returning {:error, _} instead of raising — for untrusted input.

builder(attrs_or_input, error \\ false)

concealed?(entry)

@spec concealed?(map()) :: boolean()

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.

enforce_keys()

enforce_keys(field)

example()

A sample %MishkaCmsCore.Builder.PaletteEntry{} populated from defaults + type-based fallbacks.

keys()

keys(field)

module_component?(arg1)

@spec module_component?(t() | map()) :: boolean()

Whether this entry renders through an extension module rather than a stored template.

new!(attrs)

@spec new!(map()) :: t()

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.

opens(entry)

@spec opens(map()) :: %{optional(String.t()) => String.t()}

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.

optional(entry, key)

@spec optional(t() | map(), atom()) :: term()

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.

permitted?(template, whitelist)

@spec permitted?(String.t(), MapSet.t(module())) :: boolean()

Whether template only calls modules in whitelist. A template naming an unregistered module is refused rather than compiled.

reads(component)

@spec reads(map()) :: MapSet.t()

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.

seeded(entry, skip \\ [], style \\ :blank, components \\ [])

@spec seeded(map(), [String.t()], :blank | :preview, [map()]) :: String.t()

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.

seeded_slots(entry, style \\ :blank)

@spec seeded_slots(map(), :blank | :preview) :: String.t()

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.

shapes_children?(source)

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

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.

template(entry, site, components \\ [])

@spec template(t() | map(), String.t() | nil, [map()]) :: String.t()

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.

whitelist(entries)

@spec whitelist([t() | map()]) :: MapSet.t(module())

The set of modules the editor is permitted to emit a call to — every module a registered descriptor named, and nothing else.