Problem

You want markdown pages to recognise a custom block (:::callout, :::warning, {{video:id}}) and render it to your own HTML structure. The site already uses CommonMark through bigins/scriptor-markdown-pages; the new syntax should plug into the same pipeline without rewriting the renderer.

Recipe

A plugin that wants to add markdown syntax owns the rendering for its own class of pages. Subscribe to ContentRendering, gate on the page's template (so the listener only claims the pages it should render), build a fresh CommonMark Environment with both the standard extensions and your custom one, convert the source, fill the $html slot.

Add CommonMark as a plugin dependency:

{
    "name": "acme/scriptor-callouts",
    "type": "scriptor-plugin",
    "require": {
        "php": "^8.2",
        "league/commonmark": "^2.4"
    },
    "autoload": { "psr-4": { "Acme\\ScriptorCallouts\\": "src/" } },
    "extra": { "scriptor": { "plugin": "Acme\\ScriptorCallouts\\Plugin" } }
}

The plugin's listener constructs the renderer once per request and fills the slot for pages it owns:

namespace Acme\ScriptorCallouts;

use League\CommonMark\Environment\Environment;
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
use League\CommonMark\Extension\GithubFlavoredMarkdownExtension;
use League\CommonMark\MarkdownConverter;
use Scriptor\Boot\Events\Frontend\ContentRendering;
use Scriptor\Boot\Plugin\Plugin as ScriptorPlugin;
use Scriptor\Boot\Plugin\PluginContext;

final class Plugin implements ScriptorPlugin
{
    private const OWNED_TEMPLATES = ['callout-demo', 'longform'];

    public function register(PluginContext $context): void
    {
        $context->subscribe(ContentRendering::class, [$this, 'onRendering']);
    }

    public function version(): string { return '0.1.0'; }

    public function onRendering(ContentRendering $event): void
    {
        if ($event->html !== null) return;
        if (! in_array($event->page->template, self::OWNED_TEMPLATES, strict: true)) return;

        $env = new Environment();
        $env->addExtension(new CommonMarkCoreExtension());
        $env->addExtension(new GithubFlavoredMarkdownExtension());
        $env->addExtension(new CalloutExtension());

        $converter = new MarkdownConverter($env);
        $event->html = (string) $converter->convert($event->page->content);
    }
}

The CalloutExtension itself is plain upstream CommonMark; the Scriptor-side glue is what this recipe documents. The extension's shape (parsers, renderers, node types) is covered by the CommonMark documentation. The smallest possible extension is a BlockStartParser plus a HtmlRenderer:

namespace Acme\ScriptorCallouts;

use League\CommonMark\Extension\ExtensionInterface;
use League\CommonMark\Environment\EnvironmentBuilderInterface;
// ... block parser + renderer use statements

final class CalloutExtension implements ExtensionInterface
{
    public function register(EnvironmentBuilderInterface $environment): void
    {
        $environment->addBlockStartParser(new CalloutBlockStartParser());
        $environment->addRenderer(CalloutBlock::class, new CalloutRenderer());
    }
}

Three pieces worth flagging:

  • The template gate is non-optional. Without it, this listener competes with bigins/scriptor-markdown-pages on every page; one of the two wins via registration order, and the loser's output silently disappears. Naming the templates your plugin owns is the only way to keep both renderers cohabiting.
  • The Environment is fresh per request. Building it lazily inside the listener avoids the autoload cost on pages the listener does not claim. For very high-traffic sites, lift the builder into a service in the container and cache one Environment for the request; the difference is small.
  • (string) cast on convert(). CommonMark's convert() returns a RenderedContent object, not a raw string; the cast triggers its __toString() which yields the HTML the $event->html slot expects.

Variants

Shortcode preprocessor (no extension needed)

For one-off syntax that does not need to participate in CommonMark's AST (just "find {{video:abc123}} and replace it with an iframe"), a regex substitution on the markdown source before it reaches the converter is simpler:

public function onRendering(ContentRendering $event): void
{
    if ($event->html !== null) return;
    if (! in_array($event->page->template, self::OWNED_TEMPLATES, strict: true)) return;

    $markdown = preg_replace_callback(
        '/\{\{video:([A-Za-z0-9_-]+)\}\}/',
        static fn ($m) => sprintf(
            '<iframe src="https://player.example/%s" width="640" height="360"></iframe>',
            $m[1],
        ),
        $event->page->content,
    );

    $event->html = (string) (new MarkdownConverter(self::makeEnv()))->convert($markdown);
}

Faster to write, no AST node classes to maintain. The trade is that the substituted HTML lands inside a markdown paragraph if the shortcode is in the middle of a line, which may or may not be what you want. Block-level shortcodes (own line, blank lines around them) survive markdown rendering as raw HTML.

Post-process the rendered HTML

When the source markdown is fine but you want to wrap or annotate the resulting HTML (add anchor icons to headings, lazy-load images), do it after the convert:

$html = (string) $converter->convert($event->page->content);

// Add loading="lazy" to every <img>
$html = preg_replace('/<img\s+/i', '<img loading="lazy" ', $html);

$event->html = $html;

A real DOM parser (DOMDocument with LIBXML_HTML_NOIMPLIED) is more robust than regex once the post-processing gets non-trivial.

See also