Skip to main content

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:

  1. Add to src/plugins/index.ts:
    import { manifest as myPluginManifest } from "./my-plugin/manifest";
    const BUILTINS_DEV = [
    // …
    myPluginManifest,
    ];
  2. npm run dev — the plugin appears in the Plugins tab as disabled.
  3. Click Enable to activate.
  4. 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 dashboard
  • registerRegenerationTarget(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:

  1. applyPluginRegistration(enabledFlags) is called from CmsDataContext
  2. resetRegistry() clears all currently-registered filters, actions, blocks, cards, targets
  3. For each MU plugin: manifest.register(api) runs unconditionally
  4. For each regular plugin: manifest.register(api) runs IF enabled
  5. 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:

HookTypeReceivesUse for
post.html.bodyfilter(html, post)Transform post body HTML (block markers, etc.)
page.head.extrafilter(current, baseLayoutProps)Inject <head> markup (meta tags, scripts)
page.body.endfilter(current, baseLayoutProps)Inject markup just before </body>
publish.completeaction(post, ctx)React to a publish (e.g. regenerate sitemaps)
post.unpublishedaction(post, ctx)React to an unpublish
post.deletedaction(post, ctx)React to a deletion

Combinations:

  • Plain content injection (analytics, fonts, custom CSS) → page.head.extra and page.body.end filters
  • Per-post output transformation (block markers → real HTML) → post.html.body filter
  • External file generation (sitemaps, RSS, search index) → subscribe to the three lifecycle actions; regenerate the affected files
  • Editor blocksregisterBlock(...) plus a post.html.body filter 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