Skip to content

Block extensions

A plugin can keep data of its own on every block of a document, next to the block’s fields. The website plugin keeps each block’s design this way. Core stores the data, runs the plugin’s check on save and hands it to the plugin for delivery; it never reads it.

Where the data lives

A stored block carries it in ext, by plugin id:

{ "blockId": "...", "type": "<block type id>", "fields": { "headline": "Hi" },
"ext": { "website": { "variant": "dark" }, "acme": { "tone": "warm" } } }

ext is part of the document: it is versioned, published, copied, translated, moved between environments and exported with it. The management API (RPC and export files) reads and writes it as it is. The delivery APIs never show ext; a plugin delivers its entry under a key of its own, or not at all.

Declaring it

import { definePlugin } from '@manablox/core';
export const acme = definePlugin({
name: 'acme',
blocks: {
instance: {
validate: (value) =>
typeof value === 'object' && value !== null && 'tone' in value
? []
: [{ path: ['tone'], message: 'is required' }],
strip: (value) => value,
},
publicApi: {
field: 'tone',
serialize: (value) => (value as { tone: string }).tone || undefined,
},
},
});
KeyWhat it does
instance.validate(value, context)Checks the plugin’s entry on every save. Returns problems as { path?, message }, paths relative to the entry; none means valid. context has the block’s type id, spaceId and locale. Synchronous
instance.strip(value)Optional. The value to store; undefined stores nothing, so an empty entry leaves no trace
publicApi.fieldThe block key REST and GraphQL deliver the entry at
publicApi.serialize(value)The delivered value; undefined or null leaves the key out of REST and makes the GraphQL field null
graphql.type(builder)The GraphQL type of the field, built with the schema’s Pothos builder (Builder from @manablox/api-graphql). The field is added to the Block interface after layout. Optional graphql.description

A problem fails the save with content.validation.failed, its path pointing into the block, for example ['components', 'blocks', 0, 'ext', 'acme', 'tone'].

When the plugin is off

The data follows the plugin’s flag (features.plugins.<id>) per space:

  • On save, the stored entries of plugins that are not loaded, or that are off for the space, stay as they are: core carries each block’s entry over by blockId, unchecked. Incoming values for those plugins are ignored, so a write can neither change nor remove them. A new document has nothing stored and gets none; copies and translations keep their source’s.
  • Delivery leaves the plugin’s key out (REST) or answers null (GraphQL) where the plugin is off for the space. The GraphQL field itself stays in the schema.
  • Turned back on, the plugin finds its data where it left it and delivers it again.

Data on code content types

A content type declared in code (in the config or a plugin) can bring data for a plugin under plugins.<id>. The owning plugin checks it when the config loads with contentTypeData.validate(value, type), which returns problems like instance.validate:

definePlugin({
name: 'acme',
contentTypeData: {
validate: (value, type) => (type.kind === 'block' ? [] : [{ message: 'block types only' }]),
},
});
defineContentType({ name: 'hero', kind: 'block', fields: [], plugins: { acme: { tone: 'warm' } } });

Data for a plugin that is not loaded, or that has no contentTypeData, stops the start with contentType.plugin.unknown; a failed check with contentType.plugin.invalid. The plugin reads the data from the resolved type, type.plugins?.<id>.

The website plugin

The website plugin stores a block’s design at ext.website ({ variant?, style? }, see Field types) and delivers it as design, where it has a variant or a style. A code block type brings its default design at plugins.website. The frontend types are in @manablox/site/sdk: BlockInstanceDesign, BlockInstanceStyle and DesignedBlock. A frontend on the delivery SDK passes blockExtensions: ['design'] to createClient so GraphQL blocks keep it out of fields.