Plugin blocks
Plugins can contribute editor blocks — paragraphs, embeds, layout primitives, complex content types. The block API is the same one theme blocks use — only the registration channel differs.
Plugin blocks vs theme blocks
| Plugin block | Theme block | |
|---|---|---|
| Registered via | pluginApi.registerBlock(...) | ThemeManifest.blocks: [...] |
| Available when | Plugin is enabled | Theme is active |
| Reset on | Plugin disable / re-register | Theme switch |
| Best for | Cross-theme reusable blocks (embeds, layout primitives) | Theme-specific layout primitives |
The flexweg-embeds MU plugin contributes the YouTube / Vimeo / Twitter / Spotify embed blocks via plugin registration — that's why those embeds work in every theme. The magazine theme contributes its own Most read widget as a theme block — it depends on magazine-specific CSS.
Block manifest
import type { BlockManifest } from "@flexweg/cms-runtime";
import { MyBlockIcon } from "./icon";
import { MyBlockNode } from "./node";
import { MyBlockInspector } from "./inspector";
export const myBlock: BlockManifest = {
id: "my-plugin/my-block",
titleKey: "myBlock.title",
namespace: "my-plugin",
icon: MyBlockIcon,
category: "media",
insert: (chain, ctx) => {
chain.focus().insertContent({ type: "myBlock" }).run();
},
isActive: (editor) => editor.isActive("myBlock"),
extensions: [MyBlockNode],
inspector: MyBlockInspector,
};
Then in the manifest:
register(api) {
api.registerBlock(myBlock);
api.addFilter<string>("post.html.body", transformBodyHtml);
}
Field-by-field
id
Namespaced as <plugin-id>/<block-name>. Must be unique across the entire block registry. The publisher uses the prefix to scope marker scans.
titleKey + namespace
i18n key for the inserter label. The namespace is the plugin's id (so it resolves against the plugin's i18n bundle).
titleKey: "myBlock.title", // → t("my-plugin:myBlock.title")
namespace: "my-plugin",
icon
Lucide-style React component. Renders at 18×18 in the inserter.
export function MyBlockIcon({ size = 18 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M3 3h18v18H3z" />
</svg>
);
}
Or just import a Lucide icon:
import { Image } from "lucide-react";
icon: Image,
category
One of text | media | layout | embed | advanced. Drives grouping in the inserter UI.
insert(chain, ctx)
Called when the user picks the block in the inserter. chain is a Tiptap chain bound to the editor; ctx.pickMedia is provided by the host page for blocks that need a media picker.
insert: (chain) => {
chain.focus().insertContent({ type: "myBlock", attrs: { /* defaults */ } }).run();
}
For media-aware blocks:
insert: async (chain, ctx) => {
const media = await ctx.pickMedia({ multiple: false });
if (!media) return;
chain.focus().insertContent({
type: "myBlock",
attrs: { mediaId: media.id, alt: media.alt },
}).run();
}
isActive(editor)
Predicate driving the inspector's Block tab. The inspector iterates registered blocks and shows the first whose isActive returns true.
isActive: (editor) => editor.isActive("myBlock"),
extensions
Tiptap Node / Mark / Extension list. Picked up at editor mount — toggling a plugin during a session requires reopening the editor for new extensions to take effect (the inserter list refreshes immediately though).
inspector
Optional React component rendered in the inspector's Block tab when this block is active. Receives { attrs, updateAttrs, editor }.
export function MyBlockInspector({ attrs, updateAttrs }: BlockInspectorProps) {
return (
<div>
<label>
Caption
<input
value={attrs.caption}
onChange={(e) => updateAttrs({ caption: e.target.value })}
/>
</label>
</div>
);
}
The Tiptap Node
A typical plugin block is an atom node that stores config in attributes:
import { Node } from "@tiptap/core";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { MyBlockNodeView } from "./NodeView";
export const MyBlockNode = Node.create({
name: "myBlock",
group: "block",
atom: true,
draggable: true,
addAttributes() {
return {
mediaId: { default: "" },
caption: { default: "" },
};
},
parseHTML() {
return [{ tag: 'div[data-cms-block="my-plugin/my-block"]' }];
},
renderHTML({ HTMLAttributes, node }) {
return [
"div",
{
"data-cms-block": "my-plugin/my-block",
"data-media-id": node.attrs.mediaId,
"data-caption": node.attrs.caption,
...HTMLAttributes,
},
];
},
addNodeView() {
return ReactNodeViewRenderer(MyBlockNodeView);
},
});
The <div data-cms-block="..."> marker round-trips through tiptap-markdown (the post editor has html: true set). DOMPurify allows data-* attributes by default, so markers reach your post.html.body filter intact.
The publish-time render
const MARKER_RE = /<div\s+data-cms-block="my-plugin\/my-block"[^>]*?(?:\s\/>|>.*?<\/div>)/gs;
export function transformBodyHtml(html: string, post: Post, ctx: PublishContext): string {
return html.replace(MARKER_RE, (match) => {
const attrs = parseAttrs(match);
const media = ctx.media.get(attrs["media-id"]);
if (!media) return ""; // graceful fallback
return `
<figure class="my-block">
<img src="${pickMediaUrl(mediaToView(media))}" alt="${attrs.caption}" />
<figcaption>${attrs.caption}</figcaption>
</figure>
`;
});
}
The filter receives (html, post, ctx). ctx has all the loaded data — posts, terms, media — so block renderers don't need Firestore reads.
NodeView
The NodeView is the editor preview. For plugin blocks, you'll often render a representation of what'll publish:
import { NodeViewWrapper } from "@tiptap/react";
import { useCmsData, mediaToView, pickMediaUrl } from "@flexweg/cms-runtime";
export function MyBlockNodeView({ node }: NodeViewProps) {
const { media } = useCmsData();
const view = mediaToView(media.find((m) => m.id === node.attrs.mediaId));
return (
<NodeViewWrapper>
{view ? (
<figure>
<img src={pickMediaUrl(view)} alt={node.attrs.caption} />
<figcaption>{node.attrs.caption}</figcaption>
</figure>
) : (
<div className="placeholder">No media selected</div>
)}
</NodeViewWrapper>
);
}
NodeViews can use admin context (Firestore data, hooks) because they run in the admin, not at publish time. This is one place where the static-rendering constraint doesn't apply.
Per-page detection (advanced)
If your block needs a runtime script (like Twitter's widgets.js), inject it conditionally — only on pages that have the block. Pattern from flexweg-embeds:
const detected = new Set<string>();
export function transformBodyHtml(html: string): string {
detected.clear(); // reset per-page
return html.replace(MARKER_RE, (match) => {
detected.add("my-block");
return renderBlock(match);
});
}
export function getDetectedBodyScripts(): string {
if (!detected.has("my-block")) return "";
return `<script src="https://my-block.runtime.js" defer></script>`;
}
// In manifest.register:
api.addFilter<string>("post.html.body", (html, post, ctx) => transformBodyHtml(html));
api.addFilter<string>("page.body.end", (current) => {
const scripts = getDetectedBodyScripts();
return scripts ? current + scripts : current;
});
The publisher runs post.html.body then page.body.end for the same page back-to-back, so the module-level detected Set stays consistent. Reset at the start of each transformBodyHtml call to avoid bleeding state across pages.
Continue
- Hooks (filters and actions)
- Theme blocks — same API for themes
- Editor → Core blocks — visitor-facing usage