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-pageson 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 onconvert(). CommonMark'sconvert()returns aRenderedContentobject, not a raw string; the cast triggers its__toString()which yields the HTML the$event->htmlslot 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
- Decorate page HTML in ContentRendering: the simpler shape when you do not need custom syntax, just decoration around the default render
- Serve dynamic pages from PageResolving: the sibling pattern for taking ownership of which page resolves (this recipe is about taking ownership of how the resolved page renders)
ContentRendering: the event surface, including the?string $htmlslot semanticsPage: the DTO the event hands you;templateis the field this recipe gates on- Build a Theme: Blog via Plugin:
walks
bigins/scriptor-markdown-pagesagainst a working site; the existing CommonMark consumer your custom extension stacks alongside - CommonMark extension documentation upstream