Skip to main content

Plugin manifest reference

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

Full shape

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:

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.

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:

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.

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:

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

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