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

Copy Markdown View Source

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 — and Enum.uniq_by/2 keeps 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 its cannot_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

EventPayloadFired 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

EventPayload
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

EventPayload
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

EventPayload
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

after_comment(summary)

@spec after_comment(map()) :: :ok

After a review note was written.

after_draft_save(summary)

@spec after_draft_save(map()) :: :ok

After a draft was written.

after_element_css(summary)

@spec after_element_css(map()) :: :ok

After an element's own CSS was written.

after_node(kind, summary)

@spec after_node(atom(), map()) :: :ok

Announces a completed node mutation.

after_publish(summary)

@spec after_publish(map()) :: :ok

Announces a completed publish.

after_save(summary)

@spec after_save(map()) :: :ok

Announces a completed save.

announce(event, payload, meta \\ [])

@spec announce(String.t(), map(), keyword()) :: :ok

Fires an announcement. Never blocks and never consults the return.

before_ask(payload)

@spec before_ask(map()) :: {:ok, String.t()} | {:error, String.t()}

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_class_save(payload)

@spec before_class_save(map()) :: :ok | {:error, String.t()}

Before a saved class is written. A plugin refuses by setting :error.

before_code_apply(payload)

@spec before_code_apply(map()) :: :ok | {:error, String.t()}

Before hand-written source is applied to the document. A plugin refuses by setting :error.

before_comment(payload)

@spec before_comment(map()) :: :ok | {:error, String.t()}

Before a review note is written. A plugin refuses by setting :error.

before_draft_save(payload)

@spec before_draft_save(map()) :: :ok | {:error, String.t()}

Before a draft is written. A plugin refuses by setting :error.

before_element_css(payload)

@spec before_element_css(map()) :: :ok | {:error, String.t()}

Before an element's own CSS is written. A plugin refuses by setting :error.

before_node(kind, op, meta)

@spec before_node(atom(), map(), map()) :: {:ok, map()} | {:error, String.t()}

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.

before_publish(payload)

@spec before_publish(map()) :: :ok | {:error, String.t()}

The publish gate, same contract as before_save/1.

before_save(payload)

@spec before_save(map()) :: :ok | {:error, String.t()}

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_token_set(payload)

@spec before_token_set(map()) :: {:ok, term()} | {:error, String.t()}

Before a design token is set. A plugin may normalise the value or refuse it.

collect(registry, seed)

@spec collect(atom(), list()) :: list()

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.

collect_appended(registry, seed)

@spec collect_appended(atom(), list()) :: list()

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.

containment(core_allows, meta)

@spec containment(boolean(), map()) :: {:ok, boolean(), String.t() | nil}

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.

examples(examples, meta)

@spec examples(list(), map()) :: list()

A component's examples, before the wizard shows them.

fetch(registry, seed, id)

@spec fetch(atom(), list(), term()) :: term() | nil

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.

form_created(summary)

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

form_customized(summary)

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

form_destination?()

@spec form_destination?() :: boolean()

Whether anything is registered to store page form submissions.

form_renamed(summary)

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

form_role(role, entry)

@spec form_role(atom(), map()) :: atom()

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}

form_setting(summary)

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

form_submitted(payload)

@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

gate(event, payload)

@spec gate(String.t(), map()) :: :ok | {:error, String.t()}

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.

history_query(query, meta)

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

listening?(event)

@spec listening?(String.t()) :: boolean()

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.

preset_choices(choices, meta)

@spec preset_choices(map(), map()) :: map()

The preset choices offered for a component's attributes.

preview_document(html, meta)

@spec preview_document(String.t(), map()) :: String.t()

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.

registries()

@spec registries() :: %{required(atom()) => {String.t(), atom()}}

Every registry event name the builder exposes, for documentation and tests.

serialize(heex, meta)

@spec serialize(String.t(), map()) :: String.t()

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.

session_closed(session)

@spec session_closed(map()) :: :ok

Announces that an editing session closed.

session_opened(session)

@spec session_opened(map()) :: :ok

Announces that an editing session opened.

toolbar_invoked(summary)

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

transform(event, payload)

@spec transform(String.t(), map()) :: map()

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.