Creating custom FlowEdit blocks
From the plugin documentation · View source ↗
This guide is for theme and plugin developers who want to add structured
content blocks that authors insert via FlowEdit’s / slash menu.
Custom blocks are config + fields + Twig. Authors fill a form in Admin2;
values are stored in page Markdown as a [flow-block] shortcode; the front end
renders a Twig template (overridable by the active theme).
Plugin / admin config (id, fields, template)
↓
GET /api/v1/flow-edit/settings → FlowEdit slash menu + edit modal
↓
page.md → [flow-block type="…" …]…[/flow-block]
↓
Shortcode Core → FlowBlockShortcode → Twig template
Quick start (new block type)
1. Register the block in config
Add an entry under plugins.flow-edit.custom_blocks. Prefer Admin2 →
Plugins → FlowEdit → Custom blocks, or edit
user/config/plugins/flow-edit.yaml / the plugin defaults in
flow-edit.yaml.
Example — a simple “promo” banner:
custom_blocks:
- id: promo
enabled: true
label: Promo banner
keywords: promo, banner, ad
template: flow-blocks/promo.html.twig
fields:
- name: headline
type: text
label: Headline
default: Special offer
- name: href
type: url
label: Link URL
default: /
- name: body
type: textarea
label: Body
default: ''
content: true
2. Add a Twig template
Ship a default in the FlowEdit plugin (or another plugin that adds Twig paths), or only in your theme:
Plugin default (optional):
user/plugins/flow-edit/templates/flow-blocks/promo.html.twig
Theme override / theme-only (recommended for branded markup):
user/themes/your-theme/templates/flow-blocks/promo.html.twig
{# templates/flow-blocks/promo.html.twig #}
<aside class="flow-block flow-block--promo">
{% if params.headline %}
<h2 class="flow-block__headline">{{ params.headline }}</h2>
{% endif %}
{% if content %}
<div class="flow-block__body">{{ content }}</div>
{% endif %}
{% if params.href %}
<p><a class="flow-block__link" href="{{ params.href }}">Learn more</a></p>
{% endif %}
</aside>
Grav resolves theme templates before plugin templates, so the theme file
wins when both exist. FlowEdit registers
user/plugins/flow-edit/templates via onTwigTemplatePaths.
3. Use it in FlowEdit
- Ensure the author’s account uses Content Editor: FlowEdit.
- Clear Grav cache if config was edited on disk.
- In the page editor, type
/and choose Promo banner (or searchpromo). - Fill the form and insert. The page body will contain something like:
[flow-block type="promo" headline="Special offer" href="/deals"]
Limited time — 20% off.
[/flow-block]
Block definition reference
| Key | Required | Description |
|---|---|---|
id |
yes | Stable slug: ^[a-z][a-z0-9_-]*$. Used as shortcode type and default template basename. Do not rename lightly — existing pages keep the old type. |
enabled |
no (default true) |
false → hidden from slash menu and front-end Twig output is empty. |
label |
yes | Slash menu display name. |
keywords |
no | Comma-separated slash search aliases. |
icon |
no | Lucide kebab-case name (e.g. lightbulb, max 48 chars) or emoji/glyph shown in front of the block name in the slash menu and on the block card. Unknown Lucide-shaped names fall back to text; legacy emoji still works. |
category |
no | Slash-menu grouping label (e.g. Content, Marketing). Blocks are sorted by category, then label; empty → default group. |
template |
no | Twig path matching ^flow-blocks/[a-z0-9_-]+\.html\.twig$. Empty or invalid → flow-blocks/{id}.html.twig. |
fields |
yes (non-empty) | List of field objects (see below). Entries without valid id/fields are ignored by the settings API. |
Field reference
| Key | Required | Description |
|---|---|---|
name |
yes | Attribute name: ^[a-zA-Z_][a-zA-Z0-9_]*$. Reserved: type (shortcode type). |
type |
no | text (default), textarea, url, select, toggle, list, color, number, date, icon. |
label |
yes | Label in the insert/edit modal. |
default |
no | Initial value when inserting. For list, leave blank to seed two placeholder items. |
options |
for select |
Comma-separated values, e.g. info,warning,success. |
content |
no | If true, this field is the shortcode body (between tags), not an attribute. Only one content field per block; extras are treated as normal attributes. list fields are always the content field. |
Content vs attributes
- Non-content fields → shortcode attributes:
title="…" tone="…". - Content field → inner text between
[flow-block …]and[/flow-block]. Prefertextareafor multi-line body copy. listcontent → nested[flow-item title="…"]…[/flow-item]shortcodes (max 32). Each item has a title attribute and a body.
Select / toggle / url / list
select: invalid stored values fall back to the first option ordefault.toggle: stored as1/0in attributes; Twig sees the string"1"or"0".url: must be absolutehttp(s), site-root/…, or#…; otherwise the default (or#) is used.list: editor shows a title + body repeater; storage uses[flow-item].
Color / number / date / icon
color: native color picker; stored as lowercase#rrggbb. Invalid values are stored as an empty attribute.number: numeric input (integers and decimals); non-numeric input is stored as an empty attribute.date: native date picker; stored as ISOyyyy-mm-dd.icon: Lucide kebab-case name (preferred, max 48 chars) or short emoji/glyph (max 4 code points), with a curated Lucide quick-pick row; stored as a plain attribute. Emoji values remain valid for existing content.
All four store plain shortcode attributes, so Twig templates need no extra
handling — {{ params.accent }} works like any other field.
Example list body:
[flow-block type="accordion" multiple="0" open_first="1"]
[flow-item title="What is FlowEdit?"]
A modern slash editor for Grav.
[/flow-item]
[flow-item title="Does it store Markdown?"]
Yes.
[/flow-item]
[/flow-block]
Twig contract
The shortcode renderer calls:
$this->twig->processTemplate($template, [
'type' => $blockId, // e.g. "promo"
'label' => $label, // slash label from config
'params' => $params, // map of non-content field name → string
'content' => $content, // sanitized Twig\Markup body; empty for list blocks
'items' => $items, // list of ['title' => string, 'body' => Twig\Markup]
'uid' => $uid, // stable short id for radio/details name attributes
]);
Rules:
paramsand item titles are ordinary strings and remain auto-escaped.contentand eachitem.bodyare sanitized server-side rich text, then marked safe for Twig. Render them with normal interpolation ({{ content }}and{{ item.body }}); do not add|raw.- Keep attribute values quoted in HTML:
href="{{ params.href }}". - Prefer a stable BEM-like class prefix (defaults use
flow-block/flow-block--{type}) so themes can style consistently. - If the template is missing or throws, the shortcode renders nothing and logs the block type, template, and exception.
- Accordion and tabs load
css/flow-blocks.cssvia Shortcode Core assets when rendered.
Shipped examples:
templates/flow-blocks/callout.html.twigtemplates/flow-blocks/cta.html.twigtemplates/flow-blocks/pullquote.html.twigtemplates/flow-blocks/accordion.html.twigtemplates/flow-blocks/tabs.html.twigtemplates/flow-blocks/toc.html.twig
Theme-only styling
If the plugin already ships markup you like, override CSS in the theme only:
.article-body .flow-block--callout {
border-left: 4px solid currentColor;
padding: 0.75rem 1rem;
}
To change structure (not just CSS), copy the Twig file into the theme at the same path and edit it.
Enable / disable behavior
| Kind | Where | Slash menu | Front end |
|---|---|---|---|
Custom block enabled: false |
Custom blocks list | Hidden | [flow-block] for that type renders empty |
| Built-in command off | Built-in slash commands | Hidden | Unchanged (Markdown still renders) |
Authors can still leave disabled custom blocks in saved Markdown; they simply stop displaying until re-enabled.
Settings API (editor)
FlowEdit loads definitions with:
GET /api/v1/flow-edit/settings (requires api.pages.write)
Response data shape (simplified):
{
"custom_blocks": [
{
"id": "promo",
"label": "Promo banner",
"keywords": "promo, banner, ad",
"icon": "rocket",
"category": "Marketing",
"template": "flow-blocks/promo.html.twig",
"fields": [
{
"name": "headline",
"type": "text",
"label": "Headline",
"default": "Special offer",
"options": [],
"content": false
}
]
}
],
"builtin_blocks": {
"paragraph": true,
"h1": true,
"hr": true
}
}
Only enabled custom blocks are returned. The editor builds the slash items and modal form from this payload — no editor rebuild is required when you add a block via config + Twig.
Security notes
- Attribute keys on
[flow-block]must match the allow-list for that type (type+ configured field names). Unknown keys → no output. - Treat Twig templates as trusted code and
params/ item titles as untrusted author input (rely on Twig escaping). - Content bodies allow only common prose elements (paragraphs, headings, lists, emphasis, code, quotes, tables, and safe links). Scripts, embedded media, forms, event attributes, unsafe styles, and unsafe URL schemes are removed. Only text alignment on paragraphs/headings is retained from author styles.
- Do not apply
|rawto author values. FlowEdit itself marks only sanitized body markup as safe.
Checklist
-
idis a stable lowercase slug - At least one field is defined
- At most one field has
content: true - Twig exists at
template(plugin and/or theme) - Theme override path matches exactly if branding the markup
- Block tested via
/insert, save, and front-end view -
npm testandscripts/verify-rendering.phppass - Disabled-state checked (slash hidden + front end empty)
Related files
| Path | Role |
|---|---|
blueprints.yaml |
Admin form for custom + built-in toggles |
flow-edit.yaml |
Default block definitions |
classes/BlockDefinitions.php |
Config normalization |
classes/shortcodes/FlowBlockShortcode.php |
Front-end renderer |
classes/FlowEditApiController.php |
Settings API |
src/field/custom-block.ts |
Editor node + Markdown round-trip |
src/field/settings.ts |
Client settings parse |
The storage contract freezes field identifiers, shortcode structure, and theme paths. Renaming persisted fields requires a migration note.