Skip to main content

Custom Block Types

The block editor uses an extensible block type system. Contributors can add new content block types by implementing a backend Go type for validation and storage, plus a frontend Alpine.js component for rendering and editing.

Overview

Block types define how different types of content (text, headings, images, tables, etc.) are validated, stored, and displayed within notes. The system uses a registry pattern where block types auto-register themselves at startup.

Built-in block types:

  • text - Rich text content
  • heading - Section heading (level 1-6)
  • divider - Horizontal separator line
  • gallery - Resource thumbnails in grid or list layout
  • references - Linked group cards
  • todos - Checklist items with interactive checkboxes
  • table - Tabular data (manual or query-driven)
  • calendar - Calendar with iCal sources and custom events

Plugins can register additional block types via mah.block_type() (see Plugin Lua API Reference). Plugin block types use the naming convention plugin:<plugin-name>:<type>.

Plugin Block Render Endpoint

When the block editor encounters a plugin block type, it fetches the rendered HTML from a dedicated endpoint:

GET /v1/plugins/{pluginName}/block/render?blockId={id}&mode={mode}
ParameterLocationTypeRequiredDescription
pluginNamepathstringYesThe plugin that owns the block type
blockIdqueryintegerYesThe ID of the block to render
modequerystringYes"view" or "edit"

The server loads the block from the database, verifies it belongs to the specified plugin (block type must start with plugin:<pluginName>:), then calls the plugin's render_view or render_edit Lua function with a context table containing the block's content, state, and position; the parent note's identity (id, name, note_type_id); and the plugin's settings.

Response: text/html -- the HTML fragment returned by the plugin's render function.

Error Responses:

StatusCondition
400Missing blockId, invalid mode, or block type does not belong to the plugin
404Block or note not found
500Plugin render function returned an error
503Plugin system is not available
# Render a plugin block in view mode
curl "http://localhost:8181/v1/plugins/my-plugin/block/render?blockId=42&mode=view"

# Render in edit mode
curl "http://localhost:8181/v1/plugins/my-plugin/block/render?blockId=42&mode=edit"

Calling Back from Plugin Block HTML

The HTML a plugin returns is inert on its own. window.mahBlock is how it writes back -- a bridge the block editor installs on the page, and the only supported way for plugin-rendered markup to change the block it belongs to.

MethodSignatureEffect
saveContent(blockId, content) => PromisePUT /v1/note/block?id={blockId} with { content }
updateState(blockId, state) => PromisePATCH /v1/note/block/state?id={blockId} with { state }
getBlock(blockId) => object | nullThe loaded block, from the editor's own in-memory list
<!-- Returned from render_edit: the handler runs when the user changes the field -->
<input value="Untitled"
onchange="mahBlock.saveContent(42, {label: this.value})">

Four properties of the bridge decide how a plugin should use it:

Both mutators replace, they do not merge. The request body is exactly the object passed in, so saveContent(id, {label: x}) drops every other key the block's content had. Read the current value with getBlock and spread it if you mean to change one field.

Failure is silent. Both methods catch their own errors, put the message on the editor's error banner, and do not rethrow -- so the returned promise resolves undefined whether the write succeeded or failed. A plugin cannot detect a failed write by awaiting it.

getBlock can return null for a block that is on the page. The bridge is installed before the editor loads its blocks, deliberately: plugin markup can be in the DOM before either fetch resolves, and a bridge installed after them would be missing exactly when a plugin first reaches for it. The cost is that getBlock answers from an empty list until the load completes.

It is a page-level singleton and is never torn down. On a page mounting more than one editor, the last one to initialize owns window.mahBlock.

Architecture

A complete block type implementation requires:

  1. Backend (Go) - Type definition, validation, and default values
  2. Frontend (JavaScript) - Alpine.js component for UI
  3. Template (Pongo2) - HTML structure in the block editor template
models/block_types/
├── block_type.go # Interface definition
├── registry.go # Global type registry
├── text.go # Text block implementation
├── heading.go # Heading block implementation
└── your_block.go # Your new block type

src/components/blocks/
├── index.js # Exports all block components
├── blockText.js # Text block component
├── blockHeading.js # Heading block component
└── blockYourType.js # Your new component

templates/partials/
└── blockEditor.tpl # Template with block rendering

Backend: Go Implementation

Step 1: Create the Block Type File

Create a new file in models/block_types/ for your block type:

// models/block_types/quote.go
package block_types

import (
"encoding/json"
"errors"
)

Step 2: Define Content and State Schemas

Content holds the block's persistent data. State holds UI-related data that may change without affecting the core content.

// quoteContent represents the content schema for quote blocks.
type quoteContent struct {
Text string `json:"text"`
Author string `json:"author"`
SourceURL string `json:"sourceUrl,omitempty"`
}

// quoteState represents the state schema for quote blocks.
type quoteState struct {
Collapsed bool `json:"collapsed"`
}

Step 3: Implement the BlockType Interface

Create a struct and implement all interface methods:

// QuoteBlockType implements BlockType for quotation content.
type QuoteBlockType struct{}

func (q QuoteBlockType) Type() string {
return "quote"
}

func (q QuoteBlockType) ValidateContent(content json.RawMessage) error {
var c quoteContent
if err := json.Unmarshal(content, &c); err != nil {
return err
}
if c.Text == "" {
return errors.New("quote block must have text content")
}
return nil
}

func (q QuoteBlockType) ValidateState(state json.RawMessage) error {
var s quoteState
if err := json.Unmarshal(state, &s); err != nil {
return err
}
// Collapsed is a boolean, no additional validation needed
return nil
}

func (q QuoteBlockType) DefaultContent() json.RawMessage {
return json.RawMessage(`{"text": "", "author": "", "sourceUrl": ""}`)
}

func (q QuoteBlockType) DefaultState() json.RawMessage {
return json.RawMessage(`{"collapsed": false}`)
}

Step 4: Auto-Register via init()

The init() function automatically registers the block type when the package loads:

func init() {
RegisterBlockType(QuoteBlockType{})
}

Complete Backend Example

Here is the complete quote.go file:

// models/block_types/quote.go
package block_types

import (
"encoding/json"
"errors"
)

// quoteContent represents the content schema for quote blocks.
type quoteContent struct {
Text string `json:"text"`
Author string `json:"author"`
SourceURL string `json:"sourceUrl,omitempty"`
}

// quoteState represents the state schema for quote blocks.
type quoteState struct {
Collapsed bool `json:"collapsed"`
}

// QuoteBlockType implements BlockType for quotation content.
type QuoteBlockType struct{}

func (q QuoteBlockType) Type() string {
return "quote"
}

func (q QuoteBlockType) ValidateContent(content json.RawMessage) error {
var c quoteContent
if err := json.Unmarshal(content, &c); err != nil {
return err
}
if c.Text == "" {
return errors.New("quote block must have text content")
}
return nil
}

func (q QuoteBlockType) ValidateState(state json.RawMessage) error {
var s quoteState
if err := json.Unmarshal(state, &s); err != nil {
return err
}
return nil
}

func (q QuoteBlockType) DefaultContent() json.RawMessage {
return json.RawMessage(`{"text": "", "author": "", "sourceUrl": ""}`)
}

func (q QuoteBlockType) DefaultState() json.RawMessage {
return json.RawMessage(`{"collapsed": false}`)
}

func init() {
RegisterBlockType(QuoteBlockType{})
}

Frontend: Alpine.js Component

Step 1: Create the Component File

Create a new file in src/components/blocks/:

// src/components/blocks/blockQuote.js
// The callbacks arrive as constructor arguments, and edit mode as a thunk, which
// is the convention every built-in block follows.
export function blockQuote(block, saveFn, stateFn, getEditMode) {
return {
block,
saveFn,
stateFn,
getEditMode,

// Plain data properties, so x-model can write to them
text: block?.content?.text || '',
author: block?.content?.author || '',
sourceUrl: block?.content?.sourceUrl || '',
collapsed: block?.state?.collapsed || false,

// Reading the thunk here keeps the template's `editMode` reactive
get editMode() {
return this.getEditMode ? this.getEditMode() : false;
},

// Persist content
updateQuote(text, author, sourceUrl) {
this.saveFn(this.block.id, { text, author, sourceUrl });
},

// Persist state
toggleCollapsed() {
this.collapsed = !this.collapsed;
this.stateFn(this.block.id, { collapsed: this.collapsed });
}
};
}

Step 2: Export from index.js

Add one line to src/components/blocks/index.js; the existing exports stay as they are.

// src/components/blocks/index.js
export { blockQuote } from './blockQuote.js';

Step 3: Register in main.js

In src/main.js, add blockQuote to the existing import from ./components/blocks/index.js, then register it:

// In the Alpine.data registration section:
Alpine.data('blockQuote', blockQuote);

Step 4 (optional): Update the blockEditor.js fallbacks

Neither edit below is required. GET /v1/note/block/types returns every registered type's defaultContent, defaultState, label, icon and description, and the editor replaces its whole blockTypes array with that response, so your Go DefaultContent() already reaches the browser. The maps in src/components/blockEditor.js are a pre-load fallback only, used before the API call returns.

// In getDefaultContent method, the fallback map:
getDefaultContent(type) {
const fallbackDefaults = {
text: { text: '' },
heading: { text: '', level: 2 },
divider: {},
gallery: { resourceIds: [] },
references: { groupIds: [] },
todos: { items: [] },
table: { columns: [], rows: [] },
quote: { text: '', author: '', sourceUrl: '' } // Add this
};
return fallbackDefaults[type] || {};
}

// In blockTypes array (showing built-in types plus your addition):
blockTypes: [
{ type: 'text', label: 'Text', icon: '📝' },
{ type: 'heading', label: 'Heading', icon: '🔤' },
{ type: 'divider', label: 'Divider', icon: '──' },
{ type: 'gallery', label: 'Gallery', icon: '🖼️' },
{ type: 'references', label: 'References', icon: '📁' },
{ type: 'todos', label: 'Todos', icon: '☑️' },
{ type: 'table', label: 'Table', icon: '📊' },
{ type: 'calendar', label: 'Calendar', icon: '📅' },
{ type: 'quote', label: 'Quote', icon: '💬' } // Add this
]

Step 5: Add Template in blockEditor.tpl

Add the rendering template in templates/partials/blockEditor.tpl:

{# Quote block #}
<template x-if="block.type === 'quote'">
<div x-data="blockQuote(block, (id, content) => updateBlockContent(id, content), (id, state) => updateBlockState(id, state), () => editMode)">
<template x-if="!editMode">
<blockquote class="border-l-4 border-gray-300 pl-4 italic">
<p x-text="text" class="text-lg"></p>
<template x-if="author">
<footer class="mt-2 text-sm text-gray-600">
&mdash; <span x-text="author"></span>
<template x-if="sourceUrl">
<a :href="sourceUrl" class="ml-1 text-blue-600 hover:underline" target="_blank">(source)</a>
</template>
</footer>
</template>
</blockquote>
</template>
<template x-if="editMode">
<div class="space-y-2">
<textarea
x-model="text"
@blur="updateQuote(text, author, sourceUrl)"
class="w-full min-h-[100px] p-2 border border-gray-300 rounded resize-y"
placeholder="Quote text..."
></textarea>
<input
type="text"
x-model="author"
@blur="updateQuote(text, author, sourceUrl)"
class="w-full p-2 border border-gray-300 rounded"
placeholder="Author name"
>
<input
type="url"
x-model="sourceUrl"
@blur="updateQuote(text, author, sourceUrl)"
class="w-full p-2 border border-gray-300 rounded"
placeholder="Source URL (optional)"
>
</div>
</template>
</div>
</template>

Content vs State

Understanding the difference between content and state is crucial:

Content

  • Persistent data that defines what the block contains
  • Saved with the note and synced across devices
  • Changes when the user explicitly edits the block
  • Examples: text, heading level, resource IDs, table rows

State

  • UI-related data that affects how the block displays
  • Can be user-specific or session-specific
  • May change without user editing (e.g., collapsing a section)
  • Examples: collapsed state, sort order, selected view mode

When to Use Each

Use Content ForUse State For
Text/titlesCollapsed/expanded
References to other entitiesSort column/direction
Structural data (rows, items)View mode (grid/list)
User-created identifiersTemporary selections

Example: Todos Block

// Content: The todo items themselves
{
"items": [
{ "id": "abc123", "label": "Buy groceries" },
{ "id": "def456", "label": "Write documentation" }
]
}

// State: Which items are checked (UI state)
{
"checked": ["abc123"]
}

With this separation:

  • Checking/unchecking items does not modify content
  • Different users can have different checked states
  • Content changes are tracked separately from state changes

Testing

Backend Tests

Add tests in models/block_types/registry_test.go:

func TestRegistry_GetBlockType_Quote(t *testing.T) {
bt := GetBlockType("quote")
assert.NotNil(t, bt)
assert.Equal(t, "quote", bt.Type())
}

func TestRegistry_ValidateContent_Quote_Valid(t *testing.T) {
bt := GetBlockType("quote")
content := json.RawMessage(`{"text": "To be or not to be", "author": "Shakespeare"}`)
err := bt.ValidateContent(content)
assert.NoError(t, err)
}

func TestRegistry_ValidateContent_Quote_MissingText(t *testing.T) {
bt := GetBlockType("quote")
content := json.RawMessage(`{"text": "", "author": "Someone"}`)
err := bt.ValidateContent(content)
assert.Error(t, err)
assert.Contains(t, err.Error(), "must have text content")
}

func TestRegistry_ValidateState_Quote(t *testing.T) {
bt := GetBlockType("quote")
state := json.RawMessage(`{"collapsed": true}`)
err := bt.ValidateState(state)
assert.NoError(t, err)
}

Run tests:

go test ./models/block_types/...

E2E Tests

Add Playwright tests in e2e/tests/blocks/:

test('can create and edit quote block', async ({ page }) => {
// Create a note
// Add a quote block
// Edit the quote text and author
// Verify the quote renders correctly in view mode
});

Run E2E tests:

cd e2e && npm run test:with-server

Validation Rules

  1. Validate required fields. Return clear error messages for missing data.
  2. Validate data types. Reject numbers outside valid ranges and strings that exceed length limits.
  3. Validate relationships. If referencing other entities, check that the references exist (if possible).
  4. Allow empty state. State should accept an empty object {}.
  5. Use meaningful error messages. Describe what failed and what the expected input is.
func (q QuoteBlockType) ValidateContent(content json.RawMessage) error {
var c quoteContent
if err := json.Unmarshal(content, &c); err != nil {
return err
}

// Required field validation
if c.Text == "" {
return errors.New("quote block must have text content")
}

// Length validation
if len(c.Text) > 10000 {
return errors.New("quote text cannot exceed 10000 characters")
}

// Optional URL validation
if c.SourceURL != "" {
if _, err := url.Parse(c.SourceURL); err != nil {
return errors.New("sourceUrl must be a valid URL")
}
}

return nil
}

Checklist for New Block Types

  • Create models/block_types/yourtype.go with content/state structs
  • Implement all BlockType interface methods
  • Add init() function to register the type
  • Create src/components/blocks/blockYourType.js component
  • Export from src/components/blocks/index.js
  • Register in src/main.js with Alpine.data()
  • Optional: add a pre-load fallback in blockEditor.js getDefaultContent()
  • Optional: add a pre-load entry in the blockEditor.js blockTypes array
  • Add template in templates/partials/blockEditor.tpl
  • Write backend tests in models/block_types/registry_test.go
  • Write E2E tests in e2e/tests/blocks/
  • Run npm run build-js to rebuild the frontend bundle
  • Test the new block type manually in the application