The page builder's plugin surface — every section of the editor is a MishkaInstaller hook, and
the core ships as the seeded first participant of its own events (ADR-14).
Extending the editor means registering a plugin, not patching core. A blog extension adds a "Post binding" inspector section, a brand-lint plugin adds an audit check, an agency adds a Studio tab — all through the same events the core itself uses, which is what keeps those seams honest: if a core section can be expressed as a contribution, an extension's can too.
The three shapes, following the conventions already in this codebase
- Registries are accumulators. The payload carries a seeded list a plugin appends to — never
[], always the core set first — andEnum.uniq_by/2keeps the first declaration, so a plugin can add but never silently redefine a core entry. Precedent:sidebar:menus. - Transforms pass the value itself and honour the return, so a plugin can rewrite or veto.
Precedent:
runtime:compile:css. While a transform's plugins cannot run, one that can veto — its payload carries:error, or:allowed?— refuses through itscannot_run:answer, and any other goes on with its payload unchanged. - Announcements pass a summary map with
return: false. Precedent:runtime:site:after_purge.
Catalog
Every row below has a call site. A documented event with nothing firing it is worse than no event
at all — the author writes the plugin, registers it, and watches nothing happen — so this table and
registries/0 are kept in step with the code, and an event that loses its last caller is removed.
Registries — the seeded list a plugin appends to
| Event | Payload | Fired from |
|---|---|---|
builder:components | %{entries: [PaletteEntry]} | the Components palette |
builder:blocks | %{blocks: [Block]} | the Blocks palette |
builder:elements | %{elements: [Element]} | the Elements palette |
builder:inspector:sections | %{sections: [Section]} | the Inspector |
builder:rails | %{tabs: [Tab]} | the right-hand rail |
builder:studio:tabs | %{tabs: [Tab]} | Studio |
builder:canvas:toolbar | %{actions: [Action]} | the canvas toolbar — pressed, it announces builder:canvas:toolbar:invoked |
builder:commands | %{commands: [Command]} | the command palette |
builder:audit:checks | %{checks: [Check]} | the Audit rail |
builder:page_starters | %{structures: [Starter]} | the empty-page picker |
builder:icon:libraries | %{libraries: [Library]} | the icon picker |
builder:interactions | %{actions: [Action]} | the Interactions section — see MishkaCmsCore.Builder.Action |
builder:conditions | %{rules: [Rule]} | the Show-when section — see MishkaCmsCore.Runtime.PageBuilder.Conditions |
builder:js_hooks | %{hooks: [Hook]} | the hook picker |
builder:mention:sources | %{sources: [Source]} | @-mentions in review notes |
Transforms — the value itself, and the return is honoured
| Event | Payload |
|---|---|
builder:node:before_<op> | %{op, meta, error}, op the validated Op and meta %{token, section, resource_id, site_id, actor_id} |
builder:serialize | %{heex, meta}, heex the serialized HEEx string and meta %{section, stage} |
builder:history:query | %{query, source_type, source_id, branch_id} |
builder:containment | %{allowed?, reason, parent, child, tree, components} |
builder:preview:document | %{html, resource, record_id, site_id, note} |
builder:examples | %{examples, entry, site_id} |
builder:preset:choices | %{choices, site_id} |
builder:token:before_set | %{key, value, site_id, site_name, actor_id, error} |
builder:ai:before_ask | %{prompt, scope, backend, section, resource_id, site_id, actor_id, error} |
builder:form:role | %{role, entry} |
page:form:submitted | %{form, values, page, site_id, handled} |
builder:form:role carries a component's INFERRED part in a form (:wrapper, :field,
:submit, :none, …) and the palette entry it was read from; answer another role to change it.
A kit's own extra.form declaration never passes through it. See form_role/2.
page:form:submitted is the one row here that fires from a published page rather than from the
editor, and the one whose plugins WRITE. A plugin that stored the submission answers with
handled: true, and a page reads that to tell a form with a destination from one whose values
reached nobody. It is declared here so the editor's Forms audit group and the runtime read one
catalog; form_destination?/0 answers by reading the registration rather than by sending the seam
a made-up payload to see what happens to it.
Gates — {:error, message} refuses, and the editor says so
| Event | Payload |
|---|---|
builder:save:before / builder:publish:before | %{tree, serialized, resource_id, section, error} |
builder:draft:before_save | %{section, resource_id, draft_id, label, serialized, site_id, actor_id, error} |
builder:review:before_comment | %{body, type, section, node_id, parent_comment_id, error} |
builder:code:before_apply | %{source, tab, node_id, section, resource_id, site_id, actor_id, error} |
builder:class:before_save | %{name, utilities, site_id, actor_id, error} |
builder:element_css:before_save | %{node_id, css, owner, site_id, actor_id, error} |
Announcements — return: false, nothing waits on them
| Event | Payload |
|---|---|
builder:session:after_open / after_close | %{token, section, resource_id, site_id, actor_id} |
builder:node:after_<op> | %{token, section, resource_id, site_id, actor_id, kind, node_id} |
builder:save:after / builder:publish:after | %{record_id, section, stage} |
builder:draft:after_save | %{section, resource_id, draft_id, label, serialized, site_id, actor_id} |
builder:review:after_comment | %{id, body, type, node_id, reply?, section, source_id, site_id, actor_id} |
builder:element_css:after_save | %{node_id, css, owner, site_id, actor_id, stage} |
builder:canvas:toolbar:invoked | %{action, node_id, token} |
builder:form:after_rename | %{node_id, from, to, token, change, submit, section, resource_id, site_id, actor_id} |
builder:form:after_customize | %{node_id, event, kind, section, resource_id, site_id, actor_id} |
builder:form:after_create | %{node_id, seed, token, how, section, resource_id, site_id, actor_id} |
builder:form:after_setting | %{node_id, seed, setting, value, section, resource_id, site_id, actor_id} |
builder:canvas:toolbar:invoked is the half of the toolbar registry that makes the button do
something. A descriptor's run is read by builder:interactions alone, so a toolbar entry has no
dispatch of its own; pressing one announces here instead, with the element it was pressed on and
the session token the plugin applies its own ops through. See toolbar_invoked/1.
builder:interactions is the one to read first if you are adding a feature rather than a list
entry, because it is the registry that had to grow from a list into a contract:
MishkaCmsCore.Builder.Action is what an action's run module implements, and it is the shape to
copy — the entry says what boxes the panel should draw for it, whether it runs on the client or
the server, what it writes into the document and how to read that back.
A plugin registers exactly as the dashboard widgets do:
use MishkaInstaller.Event.Hook, event: "builder:audit:checks", initial: %{priority: 50}
def call(%{checks: checks} = payload) do
{:reply, %{payload | checks: checks ++ [MyCheck.descriptor()]}}
end
Summary
Functions
After a review note was written.
After a draft was written.
After an element's own CSS was written.
Announces a completed node mutation.
Announces a completed publish.
Announces a completed save.
Fires an announcement. Never blocks and never consults the return.
Before the assistant is asked. A plugin may rewrite the prompt or refuse it.
Before a saved class is written. A plugin refuses by setting :error.
Before hand-written source is applied to the document. A plugin refuses by setting :error.
Before a review note is written. A plugin refuses by setting :error.
Before a draft is written. A plugin refuses by setting :error.
Before an element's own CSS is written. A plugin refuses by setting :error.
The transform seam every node mutation passes through. A plugin may rewrite the op or veto it by
setting :error; the session applies whatever comes back.
The publish gate, same contract as before_save/1.
The save gate — the seam content lint, broken-link checks and schema validation hang off. A veto surfaces to the editor as a flash and an inline error, and nothing is written.
Before a design token is set. A plugin may normalise the value or refuse it.
Collects a registry: fires its event with seed already in the payload and returns the resulting
list, de-duplicated by key and sorted by priority.
Collects a registry whose ORDER IS THE POINT, appending what plugins contribute to the end.
The core's containment verdict, after it has been reached.
A component's examples, before the wizard shows them.
One entry of collect_appended(registry, seed) by id, or nil. An entry that is not a map is no
entry — a contribution of the wrong shape stays inert here rather than travelling.
A form was made in the editor: how is :started (a field dropped with no form around it),
:wired (a kit form that arrived working), :moved (a field moved into a new form) or :fixed
(the audit's Fix made it, or made it work). seed and token are its name and the token in its
event names.
A form's event was customized in the Code window: its default code (event, of kind
:validate or :submit) was added to the Handlers tab, not stored yet.
Whether anything is registered to store page form submissions.
A form was renamed from the Inspector: from and to are its seeds, token the token its event
names carry (nil for untagged names), change and submit its new event names.
A component's inferred part in a form, before the editor uses it. A plugin answers another role
under :role; anything that is not :none or a MishkaCmsCore.Builder.FormRole role leaves
the inferred one.
A working form's setting was changed from the Inspector's Form panel: setting is :how
(value :typing or :send) or :address (value the web address its answers are sent to
once checked, nil when removed). seed is the form's name.
Hands a visitor's form submission to whoever stores it.
Fires a veto-capable gate. A plugin refuses by putting a message under :error in the payload it
returns; anything else lets the action through.
The timeline's read, before it runs.
Whether event has a started plugin behind it.
The preset choices offered for a component's attributes.
The assembled preview document, before it is shown.
Every registry event name the builder exposes, for documentation and tests.
The serialization transform — the AST-level sibling of the runtime's runtime:compile:css. A
plugin receives the generated HEEx and may return a rewritten string.
Announces that an editing session closed.
Announces that an editing session opened.
A canvas toolbar action a plugin contributed was pressed, on node_id, in the session token
names.
Fires a transform event and returns the (possibly rewritten) payload. The plugin's return is honoured; anything that is not a map of the same shape is discarded in favour of the original.
Functions
@spec after_comment(map()) :: :ok
After a review note was written.
@spec after_draft_save(map()) :: :ok
After a draft was written.
@spec after_element_css(map()) :: :ok
After an element's own CSS was written.
Announces a completed node mutation.
@spec after_publish(map()) :: :ok
Announces a completed publish.
@spec after_save(map()) :: :ok
Announces a completed save.
Fires an announcement. Never blocks and never consults the return.
Before the assistant is asked. A plugin may rewrite the prompt or refuse it.
This is where prompt handling belongs for anything that wants a say — a house style, a redaction pass, a refusal.
Before a saved class is written. A plugin refuses by setting :error.
Before hand-written source is applied to the document. A plugin refuses by setting :error.
Before a review note is written. A plugin refuses by setting :error.
Before a draft is written. A plugin refuses by setting :error.
Before an element's own CSS is written. A plugin refuses by setting :error.
The transform seam every node mutation passes through. A plugin may rewrite the op or veto it by
setting :error; the session applies whatever comes back.
The publish gate, same contract as before_save/1.
The save gate — the seam content lint, broken-link checks and schema validation hang off. A veto surfaces to the editor as a flash and an inline error, and nothing is written.
Before a design token is set. A plugin may normalise the value or refuse it.
Collects a registry: fires its event with seed already in the payload and returns the resulting
list, de-duplicated by key and sorted by priority.
The core's own entry wins a collision, whichever way the plugin assembled its answer — see
kept/4, which is where that stopped being a convention and became a rule.
A plugin that returns something unusable leaves the seed untouched rather than emptying the editor — a broken extension must not be able to take a rail or the palette down.
Collects a registry whose ORDER IS THE POINT, appending what plugins contribute to the end.
The shape above covers a registry of descriptors, where priority decides the order and every
entry carries a key. Some of the editor's lists are not that: the Blocks and Elements libraries,
the page starters and the icon libraries are curated, ordered lists identified by :id — or, for
icon libraries, bare modules. Running those through collect/2 would sort them by
Descriptor.key/1, which for an entry with no :key is inspect/1 of the whole map: the list
comes back in alphabetical order of its own inspected contents, silently rearranging a library
somebody curated.
So the seed keeps its order and contributions arrive after it, de-duplicated so a plugin can add
but not silently replace — which kept/4 enforces rather than trusting. Same event system, same
MishkaInstaller.Event.Hook.call/2; a different promise about order.
The core's containment verdict, after it has been reached.
A plugin may tighten it — refuse a placement the core allows — but never loosen one the core refused, because the core's rule is what keeps a document renderable.
A component's examples, before the wizard shows them.
One entry of collect_appended(registry, seed) by id, or nil. An entry that is not a map is no
entry — a contribution of the wrong shape stays inert here rather than travelling.
A PLUGIN'S CARD IS A CARD. The Blocks and Elements palettes and the empty-page chooser draw the
merged list and are handed an id back by the click, and each resolved it in its core library
alone — so a card an extension contributed was drawn and then could not be found: the palette
answered "that element is no longer on the page", and the chooser, which has no error to show,
said nothing at all. :interactions and :conditions never had this, because each owns a merged
registry/0 and asks THAT; here the core library is the only accessor there is.
seed is the list the caller rendered, because the two passes have to agree. Fired with [] the
payload breaks the promise this module makes to every plugin — "never [], always the core set
first" — so a plugin written to that promise answers the click differently than it answered the
render. With the seed in, core's own entries answer here too and the caller needs no second
lookup, while kept/4 still gives a shared id to the core. Once per placement, which is a
gesture, not a keystroke.
@spec form_created(map()) :: :ok
A form was made in the editor: how is :started (a field dropped with no form around it),
:wired (a kit form that arrived working), :moved (a field moved into a new form) or :fixed
(the audit's Fix made it, or made it work). seed and token are its name and the token in its
event names.
@spec form_customized(map()) :: :ok
A form's event was customized in the Code window: its default code (event, of kind
:validate or :submit) was added to the Handlers tab, not stored yet.
@spec form_destination?() :: boolean()
Whether anything is registered to store page form submissions.
@spec form_renamed(map()) :: :ok
A form was renamed from the Inspector: from and to are its seeds, token the token its event
names carry (nil for untagged names), change and submit its new event names.
A component's inferred part in a form, before the editor uses it. A plugin answers another role
under :role; anything that is not :none or a MishkaCmsCore.Builder.FormRole role leaves
the inferred one.
use MishkaInstaller.Event.Hook, event: "builder:form:role"
def call(%{entry: %{name: "acme-send"}} = payload), do: {:reply, %{payload | role: :submit}}
def call(payload), do: {:reply, payload}
@spec form_setting(map()) :: :ok
A working form's setting was changed from the Inspector's Form panel: setting is :how
(value :typing or :send) or :address (value the web address its answers are sent to
once checked, nil when removed). seed is the form's name.
@spec form_submitted(map()) :: :handled | :unhandled
Hands a visitor's form submission to whoever stores it.
The payload carries form (the form's param name), values (the cast map, keyed by the fields
the form declares), page and site_id. A plugin that took it answers with handled: true;
anything else means the values reached nobody, which the page logs by form and field names only,
and which the editor's form-no-destination note reports.
use MishkaInstaller.Event.Hook, event: "page:form:submitted"
def call(payload) do
{:reply, %{payload | handled: true}}
end
Fires a veto-capable gate. A plugin refuses by putting a message under :error in the payload it
returns; anything else lets the action through.
@spec history_query(Ash.Query.t(), map()) :: Ash.Query.t()
The timeline's read, before it runs.
A query rather than the loaded rows, deliberately: the sheet pages, and a plugin that dropped rows after the read would leave a page of twenty showing twelve with no way to ask for the rest. Scoping in SQL keeps the count and the page honest, and costs nothing on a table this size.
A plugin that answers with anything but a query leaves the read as it was.
Whether event has a started plugin behind it.
Reads the compiled dispatch module rather than firing the event, so asking the question about a seam whose plugins write something cannot itself cause a write. An event nobody ever registered on has no dispatch module at all, which is the same answer.
The preset choices offered for a component's attributes.
The assembled preview document, before it is shown.
Fires wherever a preview is built — the editor's own preview, and the Preview tab on every runtime modal — so a plugin that has to appear inside a preview (an overlay, a grid, a locale banner) has one place to put it. Returning anything but a string leaves the document as it was.
Every registry event name the builder exposes, for documentation and tests.
The serialization transform — the AST-level sibling of the runtime's runtime:compile:css. A
plugin receives the generated HEEx and may return a rewritten string.
@spec session_closed(map()) :: :ok
Announces that an editing session closed.
@spec session_opened(map()) :: :ok
Announces that an editing session opened.
@spec toolbar_invoked(map()) :: :ok
A canvas toolbar action a plugin contributed was pressed, on node_id, in the session token
names.
The core's own five are handled where they are — two document ops, two rail openers and the layout
panel — so nothing of the core's arrives here. Everything else the registry offers does, because a
toolbar descriptor has no run dispatch: the button used to fall through to "duplicate the
element", which is the one thing no plugin asked for.
An announcement rather than a transform: the plugin's work IS the effect, and it does that work through the token, the same funnel the editor's own ops go down.
Fires a transform event and returns the (possibly rewritten) payload. The plugin's return is honoured; anything that is not a map of the same shape is discarded in favour of the original.
While the event's plugins cannot run, a payload that can veto comes back refused: its :error
set, or its :allowed? false with a :reason. Any other comes back unchanged.