Skip to content

Overview

An entry’s content is not a string of HTML. Plumix stores it as a tree of block nodes in the entry’s content column, and a theme renders that tree by resolving each node’s name against the block registry the site booted with.

The word block covers two things, and this documentation keeps them apart.

  • A block spec is the registered definition. It carries a name, a title, the inputs an author edits, and a view that imports the module rendering it. core/rich-text is one.
  • A block node is one stored instance of a spec inside an entry. It carries an id, the spec’s name, and the values the author typed. A recipe’s opening paragraph is one.

The registry maps names to specs, and boot builds it once. The tree of nodes is per entry and lives in the database. A node does not say what it looks like. The spec its name resolves to decides that, which is why reinstalling a plugin makes its blocks render again without touching a stored tree.

Content sits under a version envelope, { version: "plumix.v2", blocks: [...] }, and lands in entries.content as JSON text. Children are not a separate field. A block that accepts children declares a slot input, and its children live inside attrs under that slot’s key, so the whole document stays one JSON value.

import { defineEntryContent } from "plumix/blocks";
export const caponata = defineEntryContent([
{
id: "caponata-intro",
name: "core/rich-text",
attrs: { body: "<p>Sicilian sweet-and-sour aubergine.</p>" },
},
{
id: "caponata-method",
name: "core/section",
attrs: {
maxWidth: "720px",
content: [
{
id: "caponata-method-text",
name: "core/rich-text",
attrs: { body: "<p>Salt the aubergine for an hour.</p>" },
},
],
},
},
]);
  1. Render the tree. A template receives the entry on data.entry, and its parsed block tree on contentBlocks. Hand that to BlockRenderer:

    import type { EntryData } from "plumix/theme";
    import type { ReactNode } from "react";
    import { BlockRenderer } from "plumix/blocks/renderer";
    import { defineTemplate } from "plumix/theme";
    export const recipeSingle = defineTemplate<EntryData>({
    render: ({ data }): ReactNode => (
    <article>
    <h1>{data.entry.title}</h1>
    <BlockRenderer content={data.entry.contentBlocks} />
    </article>
    ),
    });

    BlockRenderer needs no registry prop. The framework wraps every template render in a provider carrying the registry, the theme’s breakpoints, the active locale and the HTML allowlist, and the component reads them from there.

  2. Write some content. Open Sicilian Caponata in the editor, add a paragraph and a section, and publish. Saving writes the tree above to the entry’s content column in one request.

  3. Load /recipes/sicilian-caponata. Both nodes rendered. The rich-text node is a framework wrapper element around the block’s own <div>, and the section node is its <section> around a centring inner div. The page ships no JavaScript unless one of the blocks on it mounts an island.

Every node carries an id and a name. The rest is optional, and a different part of the render reads each key.

Key What it holds
id Unique within the entry. React keys the element by it, and when it is a plain identifier the renderer also uses it to name the node’s style class, plumix-block-<id>.
name The registered spec name, such as core/columns. A name the registry does not know renders nothing.
attrs The values behind the spec’s inputs. A slot input holds its child nodes here.
style CSS declarations in three buckets: large, medium and small.
hidden Per-device visibility, kept out of style so hiding a block never overwrites its layout display.
htmlAttrs Author-set HTML attributes on the block’s root element.
className Author CSS classes, merged alongside the generated style class.
tagName An override for the root element, taken from nine container tags: div, section, article, aside, header, footer, nav, main, figure. Anything else is ignored.
label The name shown in the editor’s layers tree. Editor metadata; the renderer ignores it.

The renderer, not the save, filters two of those keys. htmlAttrs survives only for id, title, role, lang, dir and any aria- or data- name, minus the framework’s own data-plumix- prefix, so an onclick an author typed never reaches the DOM. tagName falls back to the block’s own element when it names anything outside the nine.

The style buckets become a <style> element beside the block, scoped to that node’s class. large emits unconditionally, medium inside @media (max-width: 991px) and small inside @media (max-width: 640px). A theme moves both maxima by declaring its own tablet and mobile breakpoints. The editor canvas reads the same two numbers, so the preview breaks where the page does.

A site can let someone change a page’s content without changing its layout. The capability that decides it is entry:<type>:edit_layout, one per entry type. Authors, editors and admins hold it by default. A writer without it may still change the inputs a block marks content: true, and nothing else: they can’t add, remove, move or rename a node, and can’t touch its style, hidden, htmlAttrs, className, tagName or any other input. label is editor metadata and stays free.

Mark each input that holds what a copywriter edits, its text, link or media:

import { defineBlock } from "plumix/blocks";
export const quote = defineBlock({
name: "acme/quote",
title: "Quote",
inputs: [
{ name: "text", type: "textarea", content: true },
{ name: "cite", type: "text", content: true },
{ name: "showCite", type: "boolean" },
],
view: () => import("./quote.view"),
});

An input without the marker counts as layout, so a block with none is fully locked for such a writer, and the editor won’t let them select it. A slot input and an input with styleProperty are always layout. The server enforces the same line on every save, MCP agents included, and refuses a layout change with INVALID_BLOCK_CONTENT and a layout_change issue naming the node.

An input can be any field builder a meta box takes, so a block can hold a list of links or a group of fields. defineBlock builds it into an input named after the field’s key. contentInput marks one as a content input:

import { contentInput, defineBlock } from "plumix/blocks";
import { repeater, select, text, url } from "plumix/fields";
export const links = defineBlock({
name: "acme/links",
inputs: [
contentInput(text("heading")),
repeater("links").fields([text("label"), url("url")]),
select("tone").options(["plain", "loud"]),
],
view: () => import("./links.view"),
});

Every save settles each of these attrs the way a meta box settles its fields: coercion, the field’s constraints, its sanitize and its validate, and the stored value is what the pipeline returns. It uses the mode meta uses for the same save. An autosave or a draft save is lenient: it still sanitises and refuses an unsafe URL, but skips required fields, option lists, bounds and validate. A live save is strict. Publishing or scheduling checks every field attr strictly, the ones already stored included, as it checks the whole meta bag. A value the field refuses fails the save with INVALID_BLOCK_CONTENT and an invalid_attribute issue naming the node and the attr, whose errors are the { path, message } list a meta field gives. Outside a publish, a value the entry already holds on the same node passes unchanged, so tightening a field never blocks editing older content. The definition reaches the editor too, so a sanitize or validate callback imports nothing from the server. Slots, styleProperty, allowedBlocks and rawSlot stay on the plain input form.

Three layers contribute specs, and boot merges them in one order: the 17 core blocks first, then every block a plugin registered through registerBlock, then the blocks the theme declares. The later layer wins a name collision, so a theme can replace a plugin’s block. Neither can replace a core one. registerBlock rejects any name starting core/ before it reaches the registry, and a theme’s blocks are checked the same way, so all 17 are always present rather than opt-in and the collision rule only ever settles a plugin against the theme.

A block is two modules. The definition, passed to defineBlock, holds what the editor needs before anything renders: the name, title, icon, inputs, defaults, variations and transforms. The view module holds everything that renders or runs on the server, and the definition reaches it only through view, a function returning a dynamic import():

quote.view.tsx
import type { BlockViewProps } from "plumix/blocks";
export default function QuoteView({ attrs }: BlockViewProps) {
return <blockquote>{String(attrs.text ?? "")}</blockquote>;
}

The view module exports:

Export What it is
default The component that renders the block. Required.
loaders Per-instance server data, resolved before the page renders. See loaders below.
deps Site data many blocks share. See Data a block reads from the site.
errorFallback Renders in place of the view when a loader rejected. Without one the block renders nothing.

The view receives the block’s name beside its attrs, so one module can serve several blocks: core/table and its rows and cells share one view that switches on name. A factory that builds a block per name can also pass it through:

view: () =>
import("./card.view").then(({ cardView }) => cardView(name)),

where cardView(name) returns an object of the same shape, { default, loaders? }.

A view module can import CSS, islands and server code, and none of it is in the editor entry’s eager chunks: the JavaScript the editor loads before it can open. That is the reason for the split: the editor entry carries the definitions alone, so it doesn’t grow with a theme’s block count. Each view module is a separate chunk the editor fetches later, loader code included, so a loader still reaches the browser that way. Never import a view module, or server code, from a definition statically, or it lands in the editor entry again.

The server imports every view when the site boots, so a page renders exactly as if the views were part of the definition. The editor canvas imports the views a document uses before it shows the document, the view of a block you hover in the catalog before you insert it, and every other view once it is idle, so a block never shows a loading state.

A test that renders a block outside a running site loads its view first with loadBlockViews(registry) from plumix/test. renderBlockSpecToHtml and renderBlockTreeToHtml do it for you and return a promise.

BlockRenderer walks the nodes in order. For each one it looks up the spec, materializes the attrs, and renders the spec’s view.

Materializing is the step that makes slots work. Before it renders the view, the walker replaces every slot input the spec declares with a component that renders that slot’s children, an empty or unset slot included. The spec decides, never the value: any other attr reaches the view as stored, so an array of data stays an array even when it is empty. A container block therefore receives attrs.content as a component it drops into its JSX, and React renders the children lazily instead of the walker building them up front. That is also why a block’s rendered attrs are not JSON, while the stored ones are.

Around the view, the walker builds the node’s root element. It merges the allowlisted htmlAttrs, the author’s classes and the generated style class into one set of props. A block that declares selfSeam spreads those props onto its own element, which is how core/separator puts them on the <hr> and core/table-cell on the <td>, where a wrapper <div> would be invalid. Every other block gets the wrapper.

A block whose view module declares loaders gets its resolved data, or its errorFallback when a loader rejected.

A loader whose subject does not exist, such as an unknown share id or a spent token, can fail the whole page rather than render a fallback inside a 200. Throw pageNotFound() from plumix/support for the theme’s 404, or redirectTo(location, status?) for a redirect, which defaults to 302 and is never stored by the CDN. Any other rejection stays isolated to its block as above.

The outcome is honoured only where the block is the page’s own content, on an entry page or its ?preview= link. On an archive listing that prefetches loaders, and under ?plumix.edit or the editor’s loader refresh, it is that block’s ordinary loader error, so one entry cannot 404 an archive and the editor stays usable.

Loaders run before the template’s document(), so the head can use what a loader found. Put the lookup in one function that goes through ctx.memo, and call it from both. It runs once per request, and both callers keep its type:

find-share.ts
import type { AppContext } from "plumix";
declare function loadShare(token: string): Promise<{ title: string } | null>;
export function findShare(ctx: AppContext) {
const token = new URL(ctx.request.url).searchParams.get("token") ?? "";
return ctx.memo(`share:${token}`, () => loadShare(token));
}
share.view.tsx
import type { BlockLoaderArgs, BlockViewProps } from "plumix/blocks";
import { pageNotFound } from "plumix/support";
import { findShare } from "./find-share";
export const loaders = {
share: async ({ ctx }: BlockLoaderArgs) => {
const share = await findShare(ctx);
if (share === null) throw pageNotFound();
return share;
},
};
export default function ShareView({
loaders: data,
}: BlockViewProps<object, typeof loaders>) {
return <h1>{data.share.title}</h1>;
}
share.tsx
import { defineBlock } from "plumix/blocks";
import { defineTemplate, entry } from "plumix/theme";
import { findShare } from "./find-share";
export const shareBlock = defineBlock({
name: "acme/share",
view: () => import("./share.view"),
});
export const shareTemplate = entry(
defineTemplate({
document: async ({ ctx }) => ({
title: (await findShare(ctx))?.title ?? "Share",
}),
render: () => null,
}),
);

A block that shows copy an admin edits, or a menu, or anything else a template dep loads, exports it from its view module as deps, with the same kinds and keys a template uses, an array or the (prev) => next form, where a block’s prev is always []. The view receives the results as deps, typed per kind and per key you declared when it names typeof deps in its props. A key the loader found nothing for reads null.

ks2.view.tsx
import type {
BlockLoaderRecord,
BlockViewProps,
MaterializedAttrs,
} from "plumix/blocks";
export const deps = { settings: ["school_ks2"] } as const;
export default function Ks2View({
deps: loaded,
}: BlockViewProps<MaterializedAttrs, BlockLoaderRecord, typeof deps>) {
const title = loaded.settings.school_ks2?.title;
return <h2>{typeof title === "string" ? title : null}</h2>;
}

That block renders the school_ks2 settings group under any template, and in the editor canvas, with no template wiring. A template that writes a group into each matching block’s attrs only works under that one template and never reaches the canvas, so declare the group on the block instead.

When the keys depend on how the author configured the block, declare them as a function of its stored attributes. It must be pure, because the page evaluates it before anything renders:

stage.view.tsx
import type { BlockLoaderRecord, BlockViewProps } from "plumix/blocks";
interface StageAttrs {
readonly stage: string;
}
export const deps = (attrs: StageAttrs) => ({
settings: [`school_${attrs.stage}`],
});
export default function StageView({
attrs,
deps: loaded,
}: BlockViewProps<StageAttrs, BlockLoaderRecord, ReturnType<typeof deps>>) {
const title = loaded.settings[`school_${attrs.stage}`]?.title;
return <h2>{typeof title === "string" ? title : null}</h2>;
}

With the menu plugin installed, deps: (attrs) => ({ menus: [attrs.location] }) works the same way for a navigation block.

Before a page renders, it collects every block’s declaration from the trees it will render, which is the entry’s content or each listed entry’s on an archive. It merges them with the template’s and theme’s declarations and loads each kind once. Twenty instances of a block cost one settings read, and two eduscope/stage blocks set to different stages load both groups in that same read. The stored page carries the cache tag of every dep it loaded, so saving the school_ks2 group purges every page that renders the block.

The editor canvas starts from the values the page render loaded. When an edit declares a key the canvas has no value for, such as a changed stage or a newly inserted block, it asks the server, which evaluates the declarations itself and loads them through the same loaders. A dep that ends the request with pageNotFound() or redirectTo() reads null in the canvas, so the page stays editable. The public page still answers with the outcome.

Data that differs per instance, such as a query built from the block’s own attributes, stays a loader’s job. deps is for data many blocks share.

The site’s site settings group is read the same way, through deps: { settings: ["site"] }. A block’s context doesn’t carry it.

The editor is the usual writer, but any code holding the entry can build a tree: a seed script, an import from another system, a migration. Use defineEntryContent to stamp the envelope, as in the Overview above. A tree without the envelope fails the shape check and reads back as no content at all.

Saving through the API validates the tree against the live registry first, and reports every problem in one response rather than the first. It rejects three things: a node whose name no spec claims, a child in a slot whose allowedBlocks does not list it, and a block with requiresParent sitting anywhere but under one of the parents it names. Each error carries the path that failed, spelled blocks[2].content[0]. A separate cap rejects content whose serialized form runs past 1,000,000 bytes.

Entry content is one half of what an entry holds. The other half is its meta-box fields, which write to the entry’s meta JSON column. The entries table keeps type, status, authorId, parentId, sortOrder and the timestamps as columns of their own, outside meta. The content model decides which entry types get an editor at all, through the editor entry in the supports list. Themes receive contentBlocks on the entry projection that template data describes, and the allowlist that governs stored markup is a configuration slot, blocks.htmlAllowlist.

Read Core Blocks next. It is the complete list of what the 17 registered specs accept and what each one renders, including what the sanitizer does to markup typed into core/html.

Seven further pages in this section are not written yet, and everything they cover already ships. Authoring a Block covers defineBlock, input types, and registering a spec from a plugin or a theme. Marks lists the inline formatters a rich-text body can carry. Styles goes into the style buckets, design tokens and viewport breakpoints. Entry Content and Validation covers validateEntryContent and the errors above in detail. Variations, Patterns and Shortcodes cover preset configurations of a block, reusable arrangements of several, and the text macros that expand at render.