Skip to content

Plugin framework: defining and running a plugin

At a glance

  • definePlugin builds a plugin from lifecycle callbacks + its own Zustand slice — but it's not exported from the package root, despite its own doc comment saying otherwise (see below).
  • A plugin body gets three APIs: createTreeApi (safe tree CRUD), createMapApi (map-specific ops), createUtilsApi (network/LLM access via the host).
  • The real external extension point is PluginHostApi, passed to PluginsProvider — not definePlugin.
  • usePlugins() is the orchestrator that runs every active plugin's hooks together.

A "plugin" here is a unit of automated, per-node behavior that runs at defined points in a template's lifecycle — bootstrapping a freshly-loaded file, running before a render, deciding whether a node should render at all, post-processing a node's generated output, or answering "what could go in this field?" when the user clicks the suggestions button. The eight plugins that ship with this package (see Built-in plugins) are all built the same way, on top of the same framework described here.

definePlugin is an internal building block, not a public export

Every plugin in packages/plugins/src/definitions/ is created by calling definePlugin (from framework/define.ts) and exporting the result.

Doc comment doesn't match the export surface

definePlugin's own doc comment calls it "the main point of entry for plugin authors" — but it is not re-exported from the package's entry point. Every current call site imports it via a relative path (from '../../framework') from inside this package, and there's no @morph-mapper/plugins/framework subpath export wired up either.

In practice, "defining a plugin" today means adding a new folder under packages/plugins/src/definitions/ and registering it in registry.ts (see Built-in plugins) — not something an app outside this package can currently do by importing @morph-mapper/plugins.

What definePlugin sets up

definePlugin(opts) takes a DefinePluginOptions<S, T> — initial state S, optional settings: T, and a set of optional lifecycle callbacks (bootstrap, preRender, shouldRender, postRender, fieldSuggestions, postFieldSuggestions) — and returns a function that, when called, produces a PluginHooks object (the framework's internal, uniform interface, used by usePlugins below — every option you didn't provide becomes a no-op). Internally it:

  1. Creates a dedicated Zustand+Immer store for the plugin's own state (usePluginStore, holding slice: S plus domain/fileType) — each plugin gets its own isolated slice, not a shared one.
  2. Wraps each lifecycle callback you provided so it receives a consistent context: your callback's ctx gets state/setState (reading/writing its own slice), plus — for preRender, bootstrap, and fieldSuggestions/postFieldSuggestions — the tree/map/utils APIs described below.

The APIs a plugin body can use

Three factories build the objects a plugin's bootstrap/preRender/ fieldSuggestions callbacks receive, all working over the same Record<string, TreeNode> of tree entries described in Tree nodes and schemas:

  • createTreeApi — safe, path-based tree access: getPaths(type?), getValue(path), addKey(path, type, node), removeKey(path), getEnums(values). Every mutation is validated first (parent path must exist and be a Map, the key must not already exist, a node with dependents can't be removed) and returns a neverthrowResult rather than throwing, with a PluginFrameworkError describing what went wrong (PATH_NOT_FOUND, INVALID_PATH, KEY_ALREADY_EXISTS, KEY_IN_USE, CORRUPTED_STATE, INVALID_ARGUMENTS). It maintains its own path -> id index (constructPathToIdMapping) rather than searching the tree on every call.
  • createMapApi — built on top of createTreeApi, adds map-specific operations that themselves have real semantics elsewhere in the app, not just generic tree edits: addMapVar/removeMapVar add or remove a declared variable under the map's $declareVariable child (creating that child map on first use), addMapIterator adds an #iterator child (rejecting it if one already exists), and changeMapType updates a TreeMap's outputType (recording the previous value into formerOutputType first).
  • createUtilsApi — a small grab-bag: getFileType() (reads fileType off the global plugin store, not the calling plugin's own slice), and callRender/extractWithLLM, which are thin wrappers around callbacks supplied by the host application (see below) — a plugin body never talks to a network or an LLM directly.

Where the network/LLM calls actually come from

Several built-in plugins call out to an LLM or ask the host to render generated code. The framework itself has no networking code — instead, PluginHostApi is the contract the consuming app has to implement: createCallRender, createGetTemplate, createLLMExtract, createSetTemplatePlugins, each a factory for an async function. The app passes an implementation of this shape as props to PluginsProvider, which puts it on a plain React context; usePluginCtx() reads it back out. This — not definePlugin — is the framework's actual external extension point today: swapping in different PluginHostApi implementations changes what "render this code" or "ask an LLM" means, without touching any plugin body.

Running the plugins: usePlugins

usePlugins() is where the application actually invokes plugin hooks — it isn't part of any single plugin, it's the orchestrator that runs all of them together:

  • config/setContext — set which plugins are active for the current domain/file type, storing that in the global plugin store.

  • shouldRender({ entry }) — asks every active plugin's shouldRender and OR-combines the answers: render unless every plugin explicitly says false (any true, or all-undefined, defaults to rendering).

    No conflict resolution yet

    The source has an inline TODO on this: it's a simple OR today. Two plugins disagreeing (one true, one false) currently resolves to true with no signal that a conflict happened — the TODO notes this should probably become a priority system (required vs. optional true/false) instead.

  • postRender({ entry, template }) — pipes template through every active plugin's postRender in sequence, each one free to transform the previous plugin's output.

  • preRender/bootstrap — run every active plugin's corresponding hook in parallel (Promise.all), then call recomputeDependencies. bootstrap additionally checks getBootstrapped(plugin) first, so a plugin only bootstraps once per loaded file unless force is passed.

  • fieldSuggestions — collects suggestions from every active plugin, deduplicates them (by value+type+snippet), filters by whether the caller asked for iterator-only or non-iterator suggestions, then runs the result through every plugin's postFieldSuggestions as a final pass.

  • hydrate/serialize — convert between the global store's runtime state and the { global, registered } shape saved on a template (see TemplateSave.plugins). serialize's registered[pluginKey].slice is literally getInstanceSafe(plugin)._store.getState().slice — each plugin's own Zustand slice, persisted verbatim.

Logs and progress

Two smaller hooks read off the same global store: usePluginLogs(plugin?) and usePluginProgress(plugin?), both optionally filtered to one plugin. A plugin calls log(message, level) / progress(value, message) — passed into preRender/bootstrap context — which write to the shared logs / progress state via the store's log/setProgress actions, LogType tagging each entry as Info/Warning/Error/Success.