Plugin manifest reference

Every plugin exports a single from its .

Every plugin exports a single PluginManifest<TConfig> from its manifest.ts.

Full shape

TS
export interface PluginManifest<TConfig = unknown> {
  id: string;
  name: string;
  version: string;
  description?: string;
  author?: string;
  readme?: string;
  register: (api: PluginApi) => void;
  settings?: PluginSettingsPageDef<TConfig>;
  i18n?: Partial<Record<AdminLocale, Record<string, unknown>>>;
}

Required fields

id: string

Stable identifier used as the folder name (/admin/plugins/<id>/), the i18n namespace, the Firestore key (pluginConfigs.<id>), and the toggle key (enabledPlugins.<id>). Must be lower-case ASCII + dashes. Must not collide with built-in plugin ids (core-seo, flexweg-sitemaps, flexweg-rss, flexweg-archives, flexweg-search) or any MU plugin id (flexweg-blocks, flexweg-custom-code, flexweg-embeds, flexweg-favicon, flexweg-import, flexweg-metrics).

name: string

Human-readable label shown in the Plugins list. Localise via the i18n bundle if you want it translated.

version: string

Semver string. Shown in the plugin card.

register: (api: PluginApi) => void

The plugin's main entry. Called by applyPluginRegistration whenever the plugin should run — typically every time settings.enabledPlugins changes. The api object exposes the five primitives:

TS
interface PluginApi {
  addFilter: <T>(hook: string, fn, priority?: number) => void;
  addAction: (hook: string, fn, priority?: number) => void;
  registerBlock: (manifest: BlockManifest) => void;
  registerDashboardCard: (def: DashboardCardDef) => void;
  registerRegenerationTarget: (def: RegenerationTargetDef) => void;
}

register should be idempotent and side-effect-free at the module-level. It can be called multiple times during a session (every plugin enable/disable triggers resetRegistry() + re-register). If your plugin needs module-load setup (e.g. injecting CSS into the admin document for editor previews), do it at module-load — separately from register.

TS
ensureAdminEditorStyles();   // module-load — runs once per admin session

export const manifest: PluginManifest = {
  // …
  register(api) {
    // Runtime work — runs every time the plugin enables
    api.addFilter("post.html.body", transformBody);
  },
};

Optional fields

description?: string

One-line description shown in the Plugins list. Localise if needed.

author?: string

Plugin author / vendor — surfaced in the Plugins list next to the version. Free-form (your name, company name, GitHub handle).

readme?: string

Long-form documentation, typically your README.md imported via Vite's ?raw suffix:

TS
import readme from "./README.md?raw";

export const manifest: PluginManifest = {
  // …
  readme,
};

When present, the plugin card shows a Learn more button that opens a modal rendering the Markdown.

settings?

Plugin settings page. When defined, an entry Settings appears as a button on the plugin card and a tab in the Settings sidebar.

TS
settings: {
  navLabelKey: "title",
  defaultConfig: { /* TConfig */ },
  component: MySettingsPage,
}

See Plugin settings page.

i18n?

Bundled translations. Loaded into a dedicated namespace named after the plugin's id:

TS
import { en, fr, de, es, nl, pt, ko } from "./i18n";

i18n: { en, fr, de, es, nl, pt, ko }

The plugin's UI calls useTranslation("<plugin-id>") to scope its keys. Always provide all 7 locales even if you copy English everywhere — missing locales fall back to English at runtime, which can look confusing in mid-localised mode.

What a settings-page plugin looks like

TS
import type { PluginManifest } from "@flexweg/cms-runtime";
import { en, fr, /* … */ } from "./i18n";
import { DEFAULT_CONFIG, MySettingsPage, type MyConfig } from "./SettingsPage";
import readme from "./README.md?raw";

export const manifest: PluginManifest<MyConfig> = {
  id: "my-plugin",
  name: "My Plugin",
  version: "1.0.0",
  author: "Your Name",
  description: "What it does in one line.",
  readme,
  i18n: { en, fr, /* … */ },
  settings: {
    navLabelKey: "title",
    defaultConfig: DEFAULT_CONFIG,
    component: MySettingsPage,
  },
  register(api) {
    api.addFilter("page.head.extra", (current, props) => {
      const config = readConfig(props);
      return current + buildMetaTags(config);
    });
  },
};

The pattern: read props.site.settings.pluginConfigs.<id> inside hook handlers, fall back to DEFAULT_CONFIG, do the work.

Field interactions

settings + register

Most settings-having plugins read the live config from inside hook handlers via props.site.settings.pluginConfigs.<id>. So register doesn't need access to the config directly — it registers handlers that read the config at fire time.

settings + i18n

You almost certainly need both — settings UIs are unusable without translation. Always pair them.

register + registerBlock

Block markers in published HTML need a post.html.body filter to swap them for real markup. So registerBlock calls almost always go alongside an addFilter("post.html.body", ...) registration.

register + registerDashboardCard

Cards are self-contained React components — no extra hooks needed. Just registerDashboardCard is enough.

Build-time vs runtime

i18n, readme, settings.component, register itself — all bundled into the admin (or the plugin's external bundle) at build time.

settings.pluginConfigs.<id> is runtime — read on every hook fire, written by the settings page.

enabledPlugins.<id> is runtime — read by applyPluginRegistration to decide whether to call register.

Continue