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 contentheading- Section heading (level 1-6)divider- Horizontal separator linegallery- Resource thumbnails in grid or list layoutreferences- Linked group cardstodos- Checklist items with interactive checkboxestable- 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}
| Parameter | Location | Type | Required | Description |
|---|---|---|---|---|
pluginName | path | string | Yes | The plugin that owns the block type |
blockId | query | integer | Yes | The ID of the block to render |
mode | query | string | Yes | "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:
| Status | Condition |
|---|---|
| 400 | Missing blockId, invalid mode, or block type does not belong to the plugin |
| 404 | Block or note not found |
| 500 | Plugin render function returned an error |
| 503 | Plugin 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.
| Method | Signature | Effect |
|---|---|---|
saveContent | (blockId, content) => Promise | PUT /v1/note/block?id={blockId} with { content } |
updateState | (blockId, state) => Promise | PATCH /v1/note/block/state?id={blockId} with { state } |
getBlock | (blockId) => object | null | The 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:
- Backend (Go) - Type definition, validation, and default values
- Frontend (JavaScript) - Alpine.js component for UI
- 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">
— <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 For | Use State For |
|---|---|
| Text/titles | Collapsed/expanded |
| References to other entities | Sort column/direction |
| Structural data (rows, items) | View mode (grid/list) |
| User-created identifiers | Temporary 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
- Validate required fields. Return clear error messages for missing data.
- Validate data types. Reject numbers outside valid ranges and strings that exceed length limits.
- Validate relationships. If referencing other entities, check that the references exist (if possible).
- Allow empty state. State should accept an empty object
{}. - 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.gowith content/state structs - Implement all
BlockTypeinterface methods - Add
init()function to register the type - Create
src/components/blocks/blockYourType.jscomponent - Export from
src/components/blocks/index.js - Register in
src/main.jswithAlpine.data() - Optional: add a pre-load fallback in
blockEditor.jsgetDefaultContent() - Optional: add a pre-load entry in the
blockEditor.jsblockTypesarray - 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-jsto rebuild the frontend bundle - Test the new block type manually in the application