Skip to main content

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 blockTheme block
Registered viapluginApi.registerBlock(...)ThemeManifest.blocks: [...]
Available whenPlugin is enabledTheme is active
Reset onPlugin disable / re-registerTheme switch
Best forCross-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​