Grav 2.0 Developer Guide: Markdown Extensions

A formal API for teaching Grav's Markdown parser new syntax, with the GitHub Alerts plugin as a worked example

9 mins

If your plugin adds its own Markdown syntax — a callout block, a shortcode-like construct, a custom inline mark — you've historically done it by assigning a closure to Grav's Parsedown instance and registering a trigger character by hand. That approach still works in Grav 2.0, and it always will. But 2.0 adds a proper, typed Markdown Extension API that does the same job far more cleanly, and it's worth adopting for anything new.

The best way to show it is to port a real plugin, so in this post we take our own GitHub Markdown Alerts plugin — the one that turns a > [!NOTE] blockquote into a styled callout — and move it onto the new API end to end. We'll also cover the part that's easy to get wrong: shipping a 2.0-only plugin release so it coexists with the existing 1.7 line instead of breaking it. The full reference is on the Learn site at Markdown Extensions.

The Old Way

Here is how GitHub Markdown Alerts hooked into the parser before 2.0. It listens for onMarkdownInitialized, registers > as a block trigger, and assigns two closures to magic method names on the parser:

public function onMarkdownInitialized(Event $event)
{
    $markdown = $event['markdown'];
    $markdown->addBlockType('>', 'Alerts', true, false, 0);

    $markdown->blockAlerts = function ($line) {
        if (preg_match('/^>\s\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*$/i', $line['text'], $matches)) {
            // ...hand-build a nested ['name' => 'div', 'handler' => 'elements', ...] array...
            return $block;
        }
    };

    $markdown->blockAlertsContinue = function ($line, array $block) {
        if (isset($block['interrupted'])) {
            return;
        }
        if (!empty($block['alert'])) {
            $text = preg_replace('/^>\s?/', '', $line['text'] ?? '');
            $block['element']['text'][1]['text'][] = $text;
            return $block;
        }
    };
}

It works, but everything about it is implicit. The method names blockAlerts / blockAlertsContinue are matched by string convention, the output is a raw nested array you have to assemble by remembering the exact keys (name, handler, text, attributes), and reaching into $block['element']['text'][1]['text'] to append a line is the kind of thing that breaks silently the moment the structure shifts.

The New API

Grav 2.0 keeps the engine exactly as it is — the registry still ultimately calls addBlockType() / addInlineType() under the hood — but wraps it in a small set of typed classes under Grav\Common\Markdown:

Class / interface Role
Extension\MarkdownExtensionInterface An extension: getName(), isEnabled(), register().
Extension\AbstractMarkdownExtension Base class — accepts config in its constructor and defaults isEnabled() to true.
Extension\MarkdownExtensionRegistry Wraps the parser; add() an extension, or registerBlock() / registerInline() directly.
Extension\BlockHandlerInterface A block handler — block().
Extension\BlockContinuableInterface Optional — blockContinue() for multi-line blocks.
Extension\BlockCompletableInterface Optional — blockComplete() to finalize a block.
Extension\InlineHandlerInterface An inline handler — inline().
Element Fluent builder that compiles to a Parsedown element array.
BlockResult Optional helper that wraps an element plus its block state.

Instead of magic method names you implement interfaces, and instead of hand-built arrays you describe output with the Element builder. Let's apply all of it to the alerts plugin.

Porting the Plugin

1. The extension class

The whole of the alert logic moves into one class that extends AbstractMarkdownExtension and implements the two handler interfaces it needs — BlockHandlerInterface to open the block, BlockContinuableInterface to absorb the following lines:

namespace Grav\Plugin\GithubMarkdownAlerts;

use Grav\Common\Grav;
use Grav\Common\Markdown\BlockResult;
use Grav\Common\Markdown\Element;
use Grav\Common\Markdown\Extension\AbstractMarkdownExtension;
use Grav\Common\Markdown\Extension\BlockContinuableInterface;
use Grav\Common\Markdown\Extension\BlockHandlerInterface;
use Grav\Common\Markdown\Extension\MarkdownExtensionRegistry;
use Grav\Common\Twig\Extension\GravExtension;

class AlertsExtension extends AbstractMarkdownExtension implements BlockHandlerInterface, BlockContinuableInterface
{
    private const TYPES = 'NOTE|TIP|IMPORTANT|WARNING|CAUTION';

    public function getName(): string
    {
        return 'github-alerts';
    }

    public function register(MarkdownExtensionRegistry $registry): void
    {
        // Shares the blockquote marker '>', but index 0 runs it first so an
        // alert is detected before the built-in blockquote handler.
        $registry->registerBlock('Alerts', '>', $this, ['index' => 0]);
    }
}

register() is the only wiring: it tells the registry "I handle a block triggered by >, call me Alerts, and put me first in line for that marker." Because the class implements BlockContinuableInterface, the registry automatically marks the block as continuable — no flag to remember.

2. Opening the block with the Element builder

block() runs on the opening line. If it isn't an alert it returns null and the normal blockquote handler takes over; if it is, it builds the callout. This is where the Element builder replaces the nested array:

public function block(array $line, ?array $block = null): ?array
{
    if (!preg_match('/^>\s\[!(' . self::TYPES . ')\]\s*$/i', $line['text'], $matches)) {
        return null; // not an alert — let the normal blockquote handler run
    }

    $type = strtolower($matches[1]);

    $title = Element::create('p')
        ->attr('class', (string) $this->setting('title_class', 'md-alert-title'))
        ->setInlineText($this->titleText($type));

    // Body starts empty; blockContinue() appends each following line to it.
    $body = Element::div()
        ->attr('class', (string) $this->setting('body_class', 'md-alert-body'))
        ->setRawLines([]);

    $wrapper = Element::div()
        ->attr('class', $this->setting('wrapper_class', 'md-alert md-alert--') . $type)
        ->attr('dir', 'auto')
        ->setChildren([$title, $body]);

    // BlockResult pairs the element with the block state the continue step reads.
    return BlockResult::fromElement($wrapper)
        ->with(['alert' => true, 'type' => $type])
        ->toArray();
}

The builder picks the right Parsedown render handler for each kind of content, so you don't have to:

Method Content treatment
setInlineText(string) Parsed as inline Markdown (the title text).
setRawLines(array) Each string parsed as a block (the body, filled in line by line).
setChildren(array) Child elements rendered in order (title + body inside the wrapper).
attr() / addClass() Set attributes / classes.

BlockResult is the small helper that pairs the finished element with the extra state keys the engine and your own blockContinue() will read — here alert and type. It compiles to exactly the ['alert' => true, 'type' => ..., 'element' => [...]] array the old code returned by hand.

3. Continuing the block

Each following > ... line flows into blockContinue(), which strips the marker and appends the text to the body (the second child of the wrapper):

public function blockContinue(array $line, array $block): ?array
{
    if (isset($block['interrupted']) || empty($block['alert'])) {
        return null; // a blank line (or a foreign block) closes the alert
    }

    $text = preg_replace('/^>\s?/', '', $line['text'] ?? '');
    $block['element']['text'][1]['text'][] = $text;

    return $block;
}

(The two small private helpers — titleText() for the translated, optionally octicon-prefixed title, and setting() for reading the plugin config passed into the constructor — are unchanged logic lifted straight out of the old closure.)

4. Wiring it into the plugin

With all the logic in the extension class, the plugin's event handler collapses to two lines: build a registry around the parser and the page, and add() the extension. Compare the before (a 50-line closure) with the after:

use Grav\Common\Markdown\Extension\MarkdownExtensionRegistry;
use Grav\Plugin\GithubMarkdownAlerts\AlertsExtension;

public function onMarkdownInitialized(Event $event)
{
    $registry = new MarkdownExtensionRegistry($event['markdown'], $event['page'] ?? null);
    $registry->add(new AlertsExtension($this->config->get('plugins.github-markdown-alerts')));
}

The extension class lives in the plugin's classes/ directory, autoloaded through the plugin's existing Composer PSR-4 mapping. That's the entire port.

The result is byte-for-byte identical. Before shipping, we rendered the same set of alerts through both the old closure path and the new extension and diffed the HTML — not a single character changed. A port like this should never alter output, and verifying that with a diff is the cheapest insurance you can buy.

Backward Compatibility

The legacy closure approach isn't deprecated and isn't going anywhere. Inside the parser, the new handler routing sits behind the old closure check, so a closure assigned to $markdown->blockX always takes priority and is never shadowed by a registered handler of the same name. Every existing Markdown plugin keeps working untouched, and you're free to mix the two styles in one plugin if you're migrating gradually. The new API is the recommended path for new code; it isn't a forced migration.

Shipping a 2.0-Only Release

Here's the part that needs care. The ported plugin imports classes — MarkdownExtensionRegistry, Element, the handler interfaces — that simply do not exist on Grav 1.7. If a 1.7 user pulled this version, it would fatal the moment the event fired. So the new release has to be flagged as 2.0-only, while the previous 1.1.x line stays available for everyone still on 1.7.

That's exactly what the compatibility flag is for. In blueprints.yaml we bump to a new major and declare 2.0 only:

version: 2.0.0

compatibility:
  grav: ['2.0']

dependencies:
  - { name: grav, version: '>=2.0.0-rc.6' }

Three things are happening here, and all three matter:

  • compatibility: grav: ['2.0'] — drops '1.7' from the list. GPM will no longer offer this release to a 1.7 site, and the website badges show it as 2.0 only.
  • dependencies: grav >=2.0.0-rc.6 — the runtime gate. The extension API landed in 2.0.0-rc.6, so that's the floor; an older 2.0 pre-release won't resolve it either.
  • A major version bump (1.1.12.0.0) — this is what keeps the 1.7 line alive. GPM serves each site the newest release it's eligible for, so 1.7 users continue to receive 1.1.x while 2.0 users get 2.0.0. The two lines coexist; nobody on 1.7 is stranded or broken.

We also bumped the plugin's own composer.json floor to "php": ">=8.3.0" to match the Grav 2.0 baseline, since the extension code uses 8.3+ idioms freely.

If you only do one thing when porting a plugin onto a 2.0-only API, get this metadata right. A plugin that uses 2.0 classes but still advertises '1.7' compatibility is a fatal error waiting to happen on someone's live site. The major-version-plus-compatibility-flag combination is what makes the cutover safe.

FAQ

Do I have to rewrite my Markdown plugin for 2.0?

No. The legacy addBlockType() + closure approach is fully supported and closures take priority over the new routing, so existing plugins keep working with zero changes. Adopt the new API for new work, or when you want the readability and type-safety it brings.

Why port at all if the output is identical?

Maintainability. Typed handler objects and the Element builder are dramatically easier to read, test, and extend than magic method names and hand-built arrays. The alerts port cut the event handler from roughly fifty lines to two and moved the logic into one focused, testable class.

Can one extension register both block and inline syntax?

Yes. Implement whichever handler interfaces you need on the same class and make the matching registerBlock() / registerInline() calls in register(). The inline() handler returns ['extent' => int, 'element' => [...]]; the Learn docs have a complete @@cite@@ inline example.

How does the extension read its configuration?

AbstractMarkdownExtension takes a config value in its constructor and exposes it via getConfig(). The plugin passes $this->config->get('plugins.your-plugin') when it calls new YourExtension(...), and the extension reads keys from there — which is how the alerts plugin gets its wrapper/title/body classes and the octicons toggle.

Does the event give me access to the page?

Yes. onMarkdownInitialized carries both the parser and the page ($event['page']), and the registry holds onto the page so an extension can vary its behaviour per page — for instance reading a front-matter flag before deciding whether to register.

Quick Links

— Andy


More in the developer series: start from the top with Grav 2.0 for Plugin Developers.

Grav Premium
Turbo-charge your Grav site - from the creators of Grav