flowedit.
FLOWEDIT DOCUMENTATION

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 → FlowEditCustom 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

  1. Ensure the author’s account uses Content Editor: FlowEdit.
  2. Clear Grav cache if config was edited on disk.
  3. In the page editor, type / and choose Promo banner (or search promo).
  4. 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]. Prefer textarea for multi-line body copy.
  • list content → 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 or default.
  • toggle: stored as 1 / 0 in attributes; Twig sees the string "1" or "0".
  • url: must be absolute http(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 ISO yyyy-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:

  • params and item titles are ordinary strings and remain auto-escaped.
  • content and each item.body are 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.css via Shortcode Core assets when rendered.

Shipped examples:

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 |raw to author values. FlowEdit itself marks only sanitized body markup as safe.

Checklist

  • id is 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 test and scripts/verify-rendering.php pass
  • Disabled-state checked (slash hidden + front end empty)
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.