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.
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)
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.
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.