Creating custom FlowEdit blocks
From the plugin documentation · View source ↗
For creating blocks without template code, use the visual block builder. This guide covers the existing developer-managed Twig path; both kinds appear in the same block library.
This guide is for theme and plugin developers who want to add structured
content blocks that authors insert via FlowEdit’s / slash menu.
Twig 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 to custom_blocks in user/config/plugins/flow-edit.yaml.
The shipped examples are in flow-edit.yaml; avoid putting
site customizations in plugin defaults, which an update can replace. Preserve
the rest of the collection when editing it. The old nested custom-block settings
form has been replaced by a link to FlowEdit → Blocks. New types created in
that library are visual; register new Twig definitions in configuration.
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_-]{0,63}$. Used as shortcode type and default template basename. Saved IDs are immutable through configuration management. |
enabled |
no (default true) |
false → hidden from slash menu and front-end Twig output is empty. |
archived |
no (default false) |
Hides new insertion; existing instances remain editable and render. |
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. |
layout |
no | Versioned visual layout. Omit for Twig types. A matching Twig template takes precedence even when layout metadata exists. |
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, image. |
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; configuration saves reject extras. list fields always occupy the body. |
retired |
no (default false) |
Hides the authoring input while preserving its definition, existing values, and rendering bindings. |
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].
The URL rule above is the existing Twig field contract. Visual layout URL
bindings additionally support safe relative paths and mailto: destinations;
protocol-relative URLs, traversal, and executable schemes are rejected.
Color / number / date / icon
color: Admin2-style Huey picker with saturation, hue, hex input, swatches, and clear; stored as lowercase#rrggbb. Empty values stay empty. Alpha colors are unsupported.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.
Image
image uses Choose image to open FlowEdit’s existing media dialog. The
attribute remains a string: a page-media filename, site-root path, or accepted
image URL. It does not embed image metadata in the shortcode. Add a separate
text field for alt text. The standard visual renderer resolves page filenames
against the containing page; custom Twig templates receive the string in
params and must resolve page-local media when needed. General-media paths are
useful for defaults shared by many pages. Uploads and stock downloads persist
files even if the enclosing block dialog is later cancelled.
The Icon selector in block settings controls the type’s library/slash icon.
It is distinct from an icon field, which stores an icon value for each instance.
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. - For a Twig-only definition, 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.
For visual definitions, a matching template also overrides the generated layout. The builder’s themed preview uses that override, so changing layout elements will not change the override’s markup. Use Preview page route to test a particular page template/header/media context; test JavaScript behavior on the actual page because the builder’s iframe disables scripts and navigation.
Enable / disable behavior
| Kind | Where | Slash menu | Front end |
|---|---|---|---|
Custom block enabled: false |
Custom blocks list | Hidden | [flow-block] for that type renders empty |
Custom block archived: true |
Blocks library | Hidden | Existing instances keep rendering and can be edited |
| 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, including archived types so existing
instances can still be edited. The editor excludes archived types from insertion
and hides retired fields while retaining their values. The payload includes the
archive state and field retirement metadata. It builds the slash items and modal
forms without an editor rebuild. Definitions refresh on returning focus/visibility
to the editor and on flow-edit:blocks-updated; this does not replace the document
or reset undo history.
For definition management, use the existing config API and the dedicated preview endpoint described in the builder integration reference.
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 |
Settings, library link, and persistence blueprint for definitions |
flow-edit.yaml |
Default block definitions |
classes/BlockDefinitions.php |
Config normalization |
classes/BlockLayout.php |
Visual layout validation and rendering |
classes/BlockThemePreview.php |
Transient page rendered with the active theme |
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.