Hooks reference
This is the complete list of every filter and action the core fires. For practical guidance on how to subscribe see Creating plugins → Hooks.
This is the complete list of every filter and action the core fires. For practical guidance on how to subscribe see Creating plugins → Hooks.
Filter hooks
Filters mutate values. Each handler receives the current value, returns a new (or unchanged) value. Composed in priority order (lower runs first; default 10).
post.markdown.before
Type: filter (async)
Receives: (markdown: string, post: Post) => string
Fired by: publisher.publishPost, publisher.regenerateAllPagesAndPosts
Purpose: Mutate the raw Markdown before it's converted to HTML.
api.addFilter<string>("post.markdown.before", (md, post) => {
return md.replace(/{{\s*author\s*}}/g, post.authorId);
});
Use for shortcode-style replacements that operate on Markdown source. Most plugins prefer post.html.body since it works on the rendered HTML (after sanitisation).
post.html.body
Type: filter (async)
Receives: (html: string, post: Post, ctx: PublishContext) => string
Fired by: publisher.publishPost, publisher.regenerateAllPagesAndPosts
Purpose: Mutate the rendered HTML body after Markdown conversion + DOMPurify.
api.addFilter<string>("post.html.body", (html, post, ctx) => {
return transformBlockMarkers(html, ctx);
});
The most common filter for plugins / themes. Used by:
- Theme blocks (default, magazine, corporate)
- Plugin blocks (flexweg-blocks columns + html)
- Embed plugins (flexweg-embeds)
post.template.props
Type: filter (async)
Receives: (props: SingleTemplateProps, post: Post, ctx: PublishContext) => SingleTemplateProps
Fired by: publisher.publishPost
Purpose: Mutate the props passed to the active theme's SingleTemplate before render.
api.addFilter<SingleTemplateProps>("post.template.props", (props, post, ctx) => {
return { ...props, relatedPosts: findRelated(post, ctx.posts).slice(0, 5) };
});
Use for enriching the template with computed data (related posts, recommendations, custom CTAs).
page.head.extra
Type: filter (sync — fired via applyFiltersSync)
Receives: (current: string, baseProps: BaseLayoutProps) => string
Fired by: core/render.tsx.renderPageToHtml (every page)
Purpose: Inject markup into <head>, replacing the <meta name="x-cms-head-extra" /> sentinel.
api.addFilter<string>("page.head.extra", (current, props) => {
const config = props.site.settings.pluginConfigs?.["my-plugin"];
if (!config?.enabled) return current;
return current + `<meta name="custom" content="value" />`;
});
Synchronous — async handlers won't help. Used by:
- core-seo (Twitter Card meta tags)
- flexweg-favicon (favicon link cluster)
- flexweg-custom-code (user-supplied head injection)
- flexweg-blocks (baseline columns CSS, conditional)
- flexweg-embeds (baseline embed CSS, conditional)
page.body.end
Type: filter (sync — fired via applyFiltersSync)
Receives: (current: string, baseProps: BaseLayoutProps) => string
Fired by: core/render.tsx.renderPageToHtml (every page)
Purpose: Inject markup just before </body>, replacing the <script type="application/x-cms-body-end" /> sentinel.
api.addFilter<string>("page.body.end", (current, props) => {
return current + `<script defer src="/my-runtime.js"></script>`;
});
Synchronous. Used by:
- flexweg-custom-code (user-supplied body-end injection)
- flexweg-search (search runtime
<script>) - flexweg-embeds (per-page detected provider scripts)
menu.json.resolved
Type: filter (async)
Receives: (menu: MenuJson, ctx: MenuFilterContext) => MenuJson
Fired by: services/menuPublisher.ts.publishMenuJson
Purpose: Mutate the resolved menu structure before it's uploaded as /menu.json.
api.addFilter<MenuJson>("menu.json.resolved", (menu, ctx) => {
return {
...menu,
footer: [...menu.footer, { label: "RSS", href: "/rss.xml" }],
};
});
MenuFilterContext = { settings, posts, pages, terms }. Used by:
- flexweg-rss (footer entries for enabled feeds)
publish.additional
Type: filter (async)
Receives: (existing: AdditionalRender[], post: Post, ctx: PublishContext) => AdditionalRender[]
Fired by: publisher.publishPost, publisher.regenerateAllPagesAndPosts
Purpose: Let a plugin return more { path, html } records to upload alongside the primary file (typically per-language variants).
api.addFilter("publish.additional", async (extras, post, ctx) => {
const variants = await renderLocalized(post, ctx);
return [...extras, ...variants];
});
Orphan cleanup is automatic: the publisher diffs the returned paths against Post.lastPublishedPathsByLocale and deletes paths that disappeared. The plugin is responsible for writing lastPublishedPathsByLocale back via updatePost(...) after each save so the next publish has accurate baseline state. Used by flexweg-multilang.
publish.extraListings
Type: filter (async)
Receives: (existing: AdditionalListingRender[], ctx: PublishContext) => AdditionalListingRender[]
Fired by: publisher.regenerateListings, regenerateHomeOnly, regenerateAll
Purpose: Return additional listing files (per-locale homes, per-locale category archives) to upload. No path bookkeeping — these get regenerated wholesale every time. Used by flexweg-multilang.
rss.site.locales
Type: filter (async)
Receives: (existing: RssLocaleEntry[], { posts, terms, mediaMap, settings, config, baseUrl, xslHref }) => RssLocaleEntry[]
Fired by: flexweg-rss/generator.ts.regenerateSiteFeed (only when config.site.enabled === true)
Purpose: Return per-language site RSS feeds (e.g. <lang>/rss.xml for each non-primary language). flexweg-rss handles XML serialization + upload + orphan cleanup via config.site.lastLocalePaths.
api.addFilter("rss.site.locales", (existing, args) => {
const entries = buildLocalizedEntries(args);
return [...existing, ...entries];
});
// RssLocaleEntry = {
// language: string;
// path: string;
// channelTitle: string;
// channelLink: string;
// channelDescription: string;
// items: RssItem[];
// }
Used by flexweg-multilang.
sitemap.urlset.namespaces
Type: filter (sync)
Receives: (namespaces: string[], { settings }) => string[]
Fired by: flexweg-sitemaps/generator.ts.buildYearSitemap
Purpose: Append XML namespace declarations to each <urlset> root. Used by flexweg-multilang to add the xhtml namespace for hreflang entries.
sitemap.url.entry
Type: filter (sync)
Receives: (xml: string, { entry, settings, config }) => string
Fired by: flexweg-sitemaps/generator.ts.buildYearSitemap (per entry)
Purpose: Mutate the <url> block for a single entry. Used by flexweg-multilang to inject <xhtml:link rel="alternate" hreflang="…"> lines for each available language variant.
sitemap.urls.extra
Type: filter (async)
Receives: (existing: SitemapEntity[], { posts, pages, terms, settings, config, year }) => SitemapEntity[]
Fired by: flexweg-sitemaps/generator.ts.regenerateSitemaps
Purpose: Append extra URL entries to a year's urlset (e.g. per-language localised post URLs). Used by flexweg-multilang.
sitemap.index.extra
Type: filter (async)
Receives: (existing: SitemapIndexEntry[], { settings, config }) => SitemapIndexEntry[]
Fired by: flexweg-sitemaps/generator.ts.regenerateSitemaps (after the year sitemaps + the index are computed)
Purpose: Append <sitemap> references to the sitemap index — used by flexweg-multilang to add per-language sitemap-news-<lang>.xml references, gated on settings.pluginConfigs["flexweg-sitemaps"]?.newsEnabled === true.
sitemap.news.locales
Type: filter (async)
Receives: (existing: NewsLocaleEntry[], { posts, pages, terms, settings, config }) => NewsLocaleEntry[]
Fired by: flexweg-sitemaps/generator.ts.regenerateSitemaps (only when News sitemaps are enabled)
Purpose: Return per-language News sitemap files to write. flexweg-sitemaps builds the XML + uploads each. Paired with sitemap.index.extra so the per-language refs in the index stay in lock-step with the actual files on disk. Used by flexweg-multilang.
// NewsLocaleEntry = { language, path, entities: SitemapEntity[] }
Action hooks
Actions are side effects. Each handler runs; nothing is returned; one handler can't short-circuit another. Errors in one handler are logged and swallowed — they don't prevent others from running.
publish.before
Type: action (async)
Receives: (post: Post) => void
Fired by: publisher.publishPost
Purpose: React just before a publish starts. Limited use — typically you want publish.complete to react to a successful publish, not publish.before.
publish.after
Type: action (async)
Receives: (post: Post, ctx: PublishContext) => void
Fired by: publisher.publishPost
Purpose: Fires after the post HTML is uploaded but before the cascade regenerations. Largely identical to publish.complete — most plugins use the latter.
publish.complete
Type: action (async)
Receives: (post: Post, ctx: PublishContext) => void
Fired by: publisher.publishPost
Purpose: Fires at the very end of a successful publish, after the cascade regenerations.
api.addAction("publish.complete", async (post, ctx) => {
await regenerateMyFiles(post, ctx);
});
ctx is already patched with the post-publish state — ctx.posts reflects the new state of the post. Used by:
- flexweg-sitemaps
- flexweg-rss
- flexweg-archives
- flexweg-search
post.unpublished
Type: action (async)
Receives: (post: Post, ctx: PublishContext) => void
Fired by: publisher.unpublishPost, publisher.deletePostAndUnpublish (when the post was online)
Purpose: React to a post being taken offline.
api.addAction("post.unpublished", async (post, ctx) => {
await regenerateAffected(post, ctx);
});
ctx.posts already reflects the post being offline. Used by all four file-generating plugins.
post.deleted
Type: action (async)
Receives: (post: Post, ctx: PublishContext) => void
Fired by: publisher.deletePostAndUnpublish
Purpose: React to a post being permanently deleted.
api.addAction("post.deleted", async (post, ctx) => {
await cleanUpAfter(post, ctx);
});
Fires AFTER post.unpublished if the post was online before deletion. Used by all four file-generating plugins.
regenerate.listings.before
Type: action (async)
Receives: (ctx: PublishContext) => void
Fired by: publisher.regenerateListings
Purpose: React just before a listings regeneration pass (home + category archives) starts. Lets plugins refresh per-locale caches that the publish pass will read.
Editor extensibility registries (API ≥ 1.2)
Beyond filters and actions, plugins can extend the editor UI through dedicated registries. Each call inside register(api) adds an entry; applyPluginRegistration() resets them between enabled-plugin changes the same way it resets filters / actions.
pluginApi.registerInspectorTab(manifest)
Adds an extra tab in the post / page editor's right-side Inspector panel (next to Document and Block). Component receives { entity, updateEntity, save }.
pluginApi.registerTermEditorSection(manifest)
Adds a collapsible section in each row of the Categories / Tags page. Component receives { term, updateTerm }. Used by flexweg-multilang for per-language term translations.
pluginApi.registerEditorVariantProvider(manifest) (API ≥ 1.3)
Renders a tab strip above the editor that swaps the whole editor state (title, slug, WYSIWYG, excerpt, SEO) per variant, preserving the same Tiptap instance so blocks + drag-and-drop stay alive. Used by flexweg-multilang for per-language editing.
pluginApi.registerRegenerationTarget(manifest)
Adds an entry to the Themes ▸ Regenerate ▾ dropdown. Each target runs an async run(ctx, log) callback. Used by flexweg-sitemaps, flexweg-rss, flexweg-archives, flexweg-search for the per-plugin Force-regenerate UX.
pluginApi.registerDashboardCard(manifest)
Adds a self-contained card to the admin dashboard (below the four built-in stat cards). Component takes no props — fetches its own data. Used by flexweg-metrics.
pluginApi.registerBlock(manifest)
Registers a Tiptap-based editor block, surfaced in the / slash inserter. Blocks carry an optional inspector + a server-side renderHtml they expose via the post.html.body filter at publish time.
Hook execution model
Sync vs async
| Hook | Sync | Async |
|---|---|---|
post.markdown.before |
✓ | |
post.html.body |
✓ | |
post.template.props |
✓ | |
page.head.extra |
✓ | |
page.body.end |
✓ | |
menu.json.resolved |
✓ | |
| All actions | ✓ |
Sync hooks are called via applyFiltersSync — async handlers' Promise return values are passed straight through, so async handlers don't get awaited (they break the filter chain). Stick to sync logic for page.head.extra and page.body.end.
Priority ordering
Filters run in priority order (lower first; default 10). Actions also accept priority but order doesn't usually matter for actions (no value flows between them).
Error handling
- Filters: a thrown exception aborts the filter chain. The publisher's outer try/catch surfaces the error to the toast funnel.
- Actions: each action's exception is caught + logged. Other handlers still run. The publish itself continues.
This is intentional: a broken plugin's action handler shouldn't prevent the publish from completing or other plugins from doing their work. Filter handlers are part of the rendering pipeline so they need to either succeed or block — there's no graceful midway.
Hook fire counts per publish
For a single publishPost call:
| Hook | Times called |
|---|---|
post.markdown.before |
1 (for the post body) + N (for cascade-regenerated pages) |
post.html.body |
1 (for the post) + N (for cascade pages) |
post.template.props |
1 |
page.head.extra |
1 + N + 1 (home) + M (categories) |
page.body.end |
same as page.head.extra |
menu.json.resolved |
1 |
publish.before |
1 |
publish.after |
1 |
publish.complete |
1 |
post.unpublished |
0 |
post.deleted |
0 |
So for a small site, expect 5-15 filter calls per publish.
Adding a new hook (advanced)
If you fork the admin and want to expose a new hook, the pattern is:
Add the hook fire in the publisher (or wherever the right pipeline stage is):
await applyFilters<string>("my.new.hook", initialValue, ...args);Document it here.
Type the args via the runtime types so plugins can consume them.
For most plugins, the existing hooks are enough. Adding hooks is a fork-the-admin operation, not a plugin operation.
Continue
- Creating plugins → Hooks — practical guide
- Runtime API reference — what
@flexweg/cms-runtimeexports - Types reference — Post, Term, Media, etc.