Skip to main content

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)

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

HookSyncAsync
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:

HookTimes called
post.markdown.before1 (for the post body) + N (for cascade-regenerated pages)
post.html.body1 (for the post) + N (for cascade pages)
post.template.props1
page.head.extra1 + N + 1 (home) + M (categories)
page.body.endsame as page.head.extra
menu.json.resolved1
publish.before1
publish.after1
publish.complete1
post.unpublished0
post.deleted0

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:

  1. Add the hook fire in the publisher (or wherever the right pipeline stage is):
    await applyFilters<string>("my.new.hook", initialValue, ...args);
  2. Document it here.
  3. 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