Creating plugins
A plugin extends Flexweg CMS by hooking into the publish pipeline, the editor, the dashboard, or the admin UI. Plugins are JavaScript modules that register filters (mutate values), actions (side effects), blocks (editor content), dashboard cards, and regeneration targets — all through a single register(api) function.
The mental model is WordPress-style filters/actions, but with TypeScript typings and a much smaller API surface.
Two ways to ship a plugin
- In-tree plugins — a folder under
src/plugins/that's bundled into the admin alongside the built-ins. Best when you maintain your own admin fork. - External plugins — a ZIP with a pre-built ESM bundle, installed at runtime via the Install plugin button. Best when you want to distribute plugins independently of admin releases.
Both share the same manifest shape and runtime API. This section covers the in-tree path first; Building an external plugin bundle covers what's different for externals.
What a plugin is, structurally
src/plugins/<id>/
├── manifest.ts — the plugin definition (id, version, register fn, …)
├── i18n.ts — admin UI translations (when the plugin has a settings page)
├── (optional) SettingsPage.tsx — admin-side settings form
├── (optional) generator.ts — pure logic the manifest's hooks call
├── (optional) blocks/ — editor blocks the plugin contributes
└── (optional) README.md — long-form documentation
The minimum is just manifest.ts. Everything else is optional.
Minimum viable plugin
// src/plugins/my-plugin/manifest.ts
import type { PluginManifest } from "@flexweg/cms-runtime";
export const manifest: PluginManifest = {
id: "my-plugin",
name: "My Plugin",
version: "1.0.0",
description: "Adds a 'Powered by my brand' tag to every page footer.",
register(api) {
api.addFilter<string>("page.body.end", (current) => {
return current + '<p style="text-align:center">Powered by my brand</p>';
});
},
};
That's a working plugin. To install:
- Add to
src/plugins/index.ts:import { manifest as myPluginManifest } from "./my-plugin/manifest";const BUILTINS_DEV = [// …myPluginManifest,]; npm run dev— the plugin appears in the Plugins tab as disabled.- Click Enable to activate.
- Re-publish a page — the new tag appears at the bottom.
For external plugins, skip step 1 and use the Install plugin UI instead.
The PluginManifest
interface PluginManifest<TConfig = unknown> {
id: string;
name: string;
version: string;
description?: string;
author?: string;
readme?: string; // long-form docs (Markdown string, ?raw imported)
register: (api: PluginApi) => void;
settings?: PluginSettingsPageDef<TConfig>;
i18n?: Partial<Record<AdminLocale, Record<string, unknown>>>;
}
See Plugin manifest reference for every field.
The PluginApi
interface PluginApi {
addFilter: <T>(hook: string, fn: (current: T, ...args: unknown[]) => T | Promise<T>, priority?: number) => void;
addAction: (hook: string, fn: (...args: unknown[]) => void | Promise<void>, priority?: number) => void;
registerBlock: (manifest: BlockManifest) => void;
registerDashboardCard: (def: DashboardCardDef) => void;
registerRegenerationTarget: (def: RegenerationTargetDef) => void;
}
Five primitives. That's the entire surface.
addFilter(hook, fn)— mutate values. Returns the new value, possibly async. Composed in priority order.addAction(hook, fn)— side effect. No return value. All run, none short-circuit.registerBlock(manifest)— contribute an editor block (paragraph, image, embed, etc.)registerDashboardCard(def)— contribute a card to the admin dashboardregisterRegenerationTarget(def)— contribute an entry to the Themes → Regenerate site dropdown
See Hooks (filters and actions) for the complete list of hooks core fires.
How registration works
Whenever the user toggles a plugin or settings change:
applyPluginRegistration(enabledFlags)is called fromCmsDataContextresetRegistry()clears all currently-registered filters, actions, blocks, cards, targets- For each MU plugin:
manifest.register(api)runs unconditionally - For each regular plugin:
manifest.register(api)runs IF enabled - For each external plugin:
manifest.register(api)runs IF enabled
So toggling a plugin off/on is enough to live-reapply registrations without a reload. (Tiptap extensions are an exception — see Plugin blocks.)
Lifecycle hooks you'll most often use
The publisher fires these around every publish operation:
| Hook | Type | Receives | Use for |
|---|---|---|---|
post.html.body | filter | (html, post) | Transform post body HTML (block markers, etc.) |
page.head.extra | filter | (current, baseLayoutProps) | Inject <head> markup (meta tags, scripts) |
page.body.end | filter | (current, baseLayoutProps) | Inject markup just before </body> |
publish.complete | action | (post, ctx) | React to a publish (e.g. regenerate sitemaps) |
post.unpublished | action | (post, ctx) | React to an unpublish |
post.deleted | action | (post, ctx) | React to a deletion |
Combinations:
- Plain content injection (analytics, fonts, custom CSS) →
page.head.extraandpage.body.endfilters - Per-post output transformation (block markers → real HTML) →
post.html.bodyfilter - External file generation (sitemaps, RSS, search index) → subscribe to the three lifecycle actions; regenerate the affected files
- Editor blocks →
registerBlock(...)plus apost.html.bodyfilter to render the markers
See Hooks reference for the exhaustive list.
TypeScript
All admin internals are typed via @flexweg/cms-runtime:
import type {
PluginManifest,
PublishContext,
Post,
BaseLayoutProps,
} from "@flexweg/cms-runtime";
For in-tree plugins, this resolves to src/core/flexwegRuntime.ts via TS path alias.
For external plugins, you'll need a local cms-runtime.d.ts shim until we publish a real npm package. See the external plugin example.
Continue
- Plugin manifest reference — every field
- Hooks (filters and actions) — what to subscribe to
- Plugin blocks — editor blocks
- Dashboard cards
- Settings page
- External bundle