Custom Blocks

Define custom block types directly in your frontend configuration via the blocks option in initBridge. No Volto plugin deployment required. Each block type needs an id, title, and a blockSchema with its field properties.

initBridge() Reference

initBridge(options) opens the iframe bridge and registers your frontend's page and block configuration with the admin. Call it once during page setup when running inside the admin iframe.

import { initBridge } from '@hydra-js/hydra.js';

const bridge = initBridge({
  page:        { /* page-level blocks fields */ },
  blocks:      { /* block type registry */ },
  voltoConfig: { /* other Volto settings */ },
  onEditChange: (formData) => { /* re-render on edit */ },
  pathToApiPath: (path) => path,
  debug: false,
});

page — page-level blocks fields

Defines the regions of a page where blocks can live. page.schema.properties is keyed by field name; each entry is one region.

page: {
  schema: {
    properties: {
      blocks_layout: { title: 'Content', allowedBlocks: ['slate', 'image', 'slider'] },
      header_blocks: { title: 'Header',  allowedBlocks: ['slate'], maxLength: 3 },
      footer_blocks: { title: 'Footer',  allowedBlocks: ['slate', 'link'] },
    },
  },
}

Per-field options:

  • `title` — sidebar section title (defaults to the field name).
  • `allowedBlocks` — array of block-type names this region accepts. Acts as a per-region filter on top of the registry.
  • `allowedTemplates` — array of template URLs shown in the BlockChooser's "Templates" group for this field. See Templates.
  • `allowedLayouts` — array of template URLs shown in the Layout dropdown for this field.
  • `maxLength` — maximum number of blocks in the field.

Defaults and side effects:

  • If you don't include blocks_layout, it's auto-added with { title: 'Blocks' }.
  • The sidebar shows one section per field when no block is selected.
  • Auto-restrict: any block type that's not in any field's allowedBlocks is auto-restricted (hidden from the BlockChooser globally). To bypass, set the block's restricted to a function instead of true/false.
  • Fields not present in saved page data are auto-initialised with { items: [] } on load.
  • You can't currently change the page metadata schema itself — custom content types are created via "Site Setup > Content types" in Volto.

blocks — block type registry

Defines or overrides individual block types. Each key is the block type name (matching what appears in allowedBlocks and @type on saved blocks).

blocks: {
  slider: {                          // new custom block
    id: 'slider',
    title: 'Slider',
    icon: 'data:...',
    group: 'common',
    mostUsed: true,
    blockSchema: { properties: { /* fields */ } },
  },
  slate: {                           // override the built-in slate block
    blockSchema: { /* override */ },
  },
}

Per-block options (most are passed through to Volto's block config):

  • `id` — block type identifier (matches the key).
  • `title` — display name in the BlockChooser.
  • `icon` — icon shown in the BlockChooser (data URL or SVG component).
  • `group` — chooser group (e.g. 'common').
  • `restricted`true hides the block from the chooser; can also be a function for conditional restrictions.
  • `mostUsed` — pin to the top of the chooser.
  • `disableCustomSidebarEditForm` — set true to use only the schema form in the sidebar (no custom edit component).
  • `blockSchema` — JSON-schema-style definition of the block's fields. See Schema Enhancers below and the Block reference.
  • `fieldMappings` — block-to-block conversion rules. See Block Conversion & fieldMappings below.
  • `schemaEnhancer` — recipe-based schema modifier; supports fieldRules, inheritSchemaFrom, etc. See Schema Enhancers.

page and blocks interact via name lookup: a region's allowedBlocks: ['slate', 'slider'] references keys of the blocks registry. You can use one without the other — page alone restricts placement of built-in blocks; blocks alone registers custom types and gets a default blocks_layout region accepting everything.

Other top-level options

  • `onEditChange(formData)` — callback invoked with the new form data whenever the editor changes anything. See Live Preview › Setting Up the Bridge.
  • `pathToApiPath(path)` — function transforming a frontend path to the API/admin path on PATH_CHANGE messages. Use when your frontend embeds state (paging, filters) in URL segments that don't exist on the CMS side. See Listings › Path Transformation.
  • `voltoConfig` — passes additional Volto config (non-block settings) through to the admin. Future home for things like slate formats (TODO #109) and toolbar actions.
  • `debug`true enables verbose console logging in the bridge. Default false.

Returns

The Bridge instance, which exposes additional API methods you can call from the frontend (e.g. getAccessToken(), sendBlockUpdate(), sendBlockAction()). See Advanced › Custom Sidebar UI for those.

Defining a custom block

const bridge = initBridge({
    page: {
        schema: {
            properties: {
                blocks_layout: {
                    title: 'Content',
                    allowedBlocks: ['slate', 'image', 'video', 'slider'],
                },
            },
        },
    },
    blocks: {
        slider: {
            id: 'slider',
            title: 'Slider',
            icon: 'data:...',
            group: 'common',
            restricted: false,
            mostUsed: true,
            disableCustomSidebarEditForm: false,
            blockSchema: {
                properties: {
                    slider_timing: {
                        title: 'Delay',
                        widget: 'float',
                    },
                    slides: {
                        title: 'Slides',
                        widget: 'blocks_layout',
                        allowedBlocks: ['slide', 'image'],
                        defaultBlockType: 'slide',
                    }
                },
            }
        },
        slide: {
            id: 'slide',
            title: 'Slide',
            blockSchema: {
                properties: {
                    url: { title: 'Link', widget: 'url' },
                    title: { title: 'Title' },
                    image: { title: 'Image', widget: 'image' },
                    description: { title: 'Description',
                                   widget: 'slate' },
                },
            },
        },
    },
});

Child block types (like slide above) must be defined at the top level of blocks. You can also:

  • Set restricted: true to hide a block from the block chooser (only usable as child blocks)
  • Set mostUsed: true to pin a block to the top of the chooser
  • Set disableCustomSidebarEditForm: true to use only the schema form in the sidebar (no custom edit component)
  • Use fieldsets in the schema to organize fields into tabs

A `widget: 'slate'` field holds one top-level node. A slate field — like description on the slide above — stores a single paragraph, heading, or list, not a document of several. Pasting or typing multiple paragraphs into it flattens them back into one node; only the built-in slate block splits multi-node content into separate blocks. Design slate fields for single-node content, and use a blocks_layout/object_list of slate blocks when you need several. See Visual Editing › One top-level node per slate field.

Schema Enhancers

Schema enhancers modify block schemas dynamically:

const bridge = initBridge({
    blocks: {
        myBlock: {
            blockSchema: {
                properties: {
                    mode: {
                        title: 'Mode', widget: 'select',
                        choices: [['simple', 'Simple'], ['advanced', 'Advanced']],
                    },
                    advancedOptions: { title: 'Advanced Options', type: 'string' },
                },
            },
            schemaEnhancer: {
                fieldRules: {
                    advancedOptions: { when: { mode: 'advanced' }, else: false },
                },
            },
        },
    },
});

`fieldRules` — add, remove, or conditionally modify field definitions. The value for each rule key can be:

  • false — always hide the field
  • { set: { title: '...', widget: '...' } } — always add or replace the field definition
  • { when: { fieldName: value }, else: false } — show only when condition met
  • { when: { fieldName: { gte: 2 } }, set: { ... } } — conditional definition override
  • [rule, rule, ...] — switch: first matching rule wins. A bare false in the array is a catch-all hide: [{ when: A }, { when: B }, false] shows on A or B, hides otherwise.
  • 'parent.child': false — hide a field inside a widget's inner schema

Condition operators: is, isNot, isSet, isNotSet, gt, gte, lt, lte.

Field paths: ../field for the parent block's field, /field for a page metadata field.

Block Conversion & fieldMappings

fieldMappings (plural) on a block config defines how fields map between block types. This enables three things:

  • "Convert to..." UI action — editors can convert a block to another type (e.g. teaser → image).
  • Listing item types — query results are mapped to item blocks via @default (see Listings).
  • Synchronised container children — a parent controls child type, all children convert together (see Container Blocks › Synchronised Block Types).

Each key in fieldMappings is either a specific block type name or `@default`.

@default — the canonical content shape

@default is a virtual type representing canonical Plone content item fields: @id, title, description, image. These are the same fields that listing query results provide. A block with fieldMappings['@default'] is saying "I can be populated from standard content item fields." The keys in @default must only use these four canonical fields — using other keys (e.g. label, field, required) is invalid and produces a console warning.

Explicit type-to-type mappings

Use these when blocks share fields that aren't part of the @default set — for example, facet types sharing { title, field, hidden } or form field types sharing { label, description, required }.

// Content item types: use @default (canonical fields) + explicit cross-mappings
teaser: {
    fieldMappings: {
        '@default': { '@id': 'href', 'title': 'title', 'image': 'preview_image' },
        image: { 'href': 'href', 'alt': 'title', 'url': 'preview_image' },
    },
},
image: {
    fieldMappings: {
        '@default': { '@id': 'href', 'title': 'alt', 'image': 'url' },
        teaser: { 'href': 'href', 'title': 'alt', 'preview_image': 'url' },
    },
},

// Non-content types: use explicit hub-type mappings (NOT @default).
// All facet types map through checkboxFacet as a hub:
selectFacet:  { fieldMappings: { checkboxFacet: { title: 'title', field: 'field', hidden: 'hidden' } } },
checkboxFacet: { fieldMappings: { selectFacet: { /* ... */ }, daterangeFacet: { /* ... */ } } },

Conversion graph rules

  • Explicit fieldMappings[typeName] always creates a conversion edge.
  • @default only creates edges between types that both have valid @default mappings (keys from { @id, title, description, image }). Types with non-canonical @default keys are ignored.
  • Types without fieldMappings never appear in the "Convert to..." menu.
  • Transitive conversions use paths through intermediate types (e.g. hero → teaser → image).
  • Unmapped fields are kept in the data so converting back restores them.

Mapping value format

A mapping value is either a string (simple field rename) or { field, type } (rename with type conversion):

{
    "@id": { "field": "href", "type": "link" },
    "title": "title",
    "description": "description",
    "image": "preview_image"
}

When type is specified, the value is converted at runtime:

Type

Conversion

string

Arrays joined with ", "; image objects resolved to URL string

link

String wrapped as [{ "@id": value }] (Volto link format)

image

Pass through (expects { "@id", image_field, image_scales })

array

Non-arrays wrapped in [value]

(none)

Copied as-is

FieldMappingWidget

When a parent block has mappingField set in its inheritSchemaFrom recipe, the admin sidebar shows a widget that lets editors configure field mappings visually:

  • Shows the @default source fields (@id, title, description, image) on the left.
  • For each source field, lets the editor pick a field from the selected child type's schema.
  • Auto-detects the conversion type from the target field definition (e.g. object_browser with mode=linktype: "link").
  • Saves the result as fieldMapping (singular) on the block data.

The saved fieldMapping is read at render time by expandListingBlocks — no block registry access needed at render time.

HTML Paste Support (TODO)

When the editor pastes rich HTML into the page, Hydra will eventually be able to recognise it as a custom block by matching against a CSS selector mapping. The proposed shape:

video: {
    fieldMappings: {
        'css:video': { 'src': 'url', 'caption[@class="alt"]': 'alt' },
    },
}

The css:<selector> key in fieldMappings matches a pasted HTML element; the value maps element attributes to block fields. Not yet implemented — open question on whether this should run via htmlTagsToSlate (bypassing slate conversion) or be encoded into slate so attributes/classes survive.