Plugin framework: defining and running a plugin
At a glance
definePluginbuilds 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 toPluginsProvider— notdefinePlugin. 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:
- Creates a dedicated Zustand+Immer store for the plugin's own state (
usePluginStore, holdingslice: Splusdomain/fileType) — each plugin gets its own isolated slice, not a shared one. - Wraps each lifecycle callback you provided so it receives a consistent context: your callback's
ctxgetsstate/setState(reading/writing its own slice), plus — forpreRender,bootstrap, andfieldSuggestions/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 aMap, the key must not already exist, a node with dependents can't be removed) and returns aneverthrowResultrather than throwing, with aPluginFrameworkErrordescribing what went wrong (PATH_NOT_FOUND,INVALID_PATH,KEY_ALREADY_EXISTS,KEY_IN_USE,CORRUPTED_STATE,INVALID_ARGUMENTS). It maintains its ownpath -> idindex (constructPathToIdMapping) rather than searching the tree on every call.createMapApi— built on top ofcreateTreeApi, adds map-specific operations that themselves have real semantics elsewhere in the app, not just generic tree edits:addMapVar/removeMapVaradd or remove a declared variable under the map's$declareVariablechild (creating that child map on first use),addMapIteratoradds an#iteratorchild (rejecting it if one already exists), andchangeMapTypeupdates aTreeMap'soutputType(recording the previous value intoformerOutputTypefirst).createUtilsApi— a small grab-bag:getFileType()(readsfileTypeoff the global plugin store, not the calling plugin's own slice), andcallRender/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'sshouldRenderand OR-combines the answers: render unless every plugin explicitly saysfalse(anytrue, 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, onefalse) currently resolves totruewith 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 })— pipestemplatethrough every active plugin'spostRenderin 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 callrecomputeDependencies.bootstrapadditionally checksgetBootstrapped(plugin)first, so a plugin only bootstraps once per loaded file unlessforceis passed.fieldSuggestions— collects suggestions from every active plugin, deduplicates them (byvalue+type+snippet), filters by whether the caller asked for iterator-only or non-iterator suggestions, then runs the result through every plugin'spostFieldSuggestionsas a final pass.hydrate/serialize— convert between the global store's runtime state and the{ global, registered }shape saved on a template (seeTemplateSave.plugins).serialize'sregistered[pluginKey].sliceis literallygetInstanceSafe(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.