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.