Problem
You want every page (or every page that matches a template) to carry an extra HTML fragment around its body: a reading-time badge, an auto-generated table of contents, an embed snippet, a banner. The decoration needs to apply to whatever the page tree serves without touching the theme templates.
Recipe
ContentRendering fires inside Site::renderContent(). The
listener slot is ?string $html: set it to a non-null string and
Scriptor uses that as the rendered content, skipping its default
markdown pipeline. Leave it null and the default
$site->sanitizer->markdown($page->content) runs.
There is one caveat the event surface makes explicit: today only the original Page is exposed, not the in-flight default render. A "decorator" in this pipeline does not wrap an existing string; it re-renders the content itself, then wraps that string and fills the slot. The default renderer never runs.
namespace Acme\ReadingTime;
use League\Container\Container;
use Psr\Log\LoggerInterface;
use Scriptor\Boot\Events\Frontend\ContentRendering;
use Scriptor\Boot\Frontend\Sanitizer;
use Scriptor\Boot\Plugin\Plugin as ScriptorPlugin;
use Scriptor\Boot\Plugin\PluginContext;
final class Plugin implements ScriptorPlugin
{
public function __construct(
private readonly Container $container,
private readonly LoggerInterface $logger,
) {}
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; // earlier listener won
$sanitizer = $this->container->get(Sanitizer::class);
$rendered = $sanitizer->markdown($event->page->content);
$words = str_word_count(strip_tags($rendered));
$minutes = max(1, (int) ceil($words / 220));
$event->html = sprintf(
'<aside class="reading-time">%d min read</aside>%s',
$minutes,
$rendered,
);
}
}
Three pieces worth flagging:
$event->html !== nullearly return is the cooperation convention. The bundledscriptor-markdown-pagesplugin subscribes to this event too; both plugins on the same page would otherwise step on each other's output. First writer wins.$sanitizer->markdown(...)re-runs the exact pipeline Scriptor would have run by default. If you only want to add a prefix or suffix to the default output, this is currently the shape: re-render, then concatenate. (A future event revision may expose the in-flight render, at which point a true wrapper shape becomes possible without re-rendering.)- The 220-words-per-minute figure is the conventional average-reading-speed constant; adjust to taste.
Variants
Only decorate certain templates
Most decorations apply to a subset of pages. Gate on the page's template field before doing the work:
public function onRendering(ContentRendering $event): void
{
if ($event->html !== null) return;
if (! in_array($event->page->template, ['post', 'longform'], strict: true)) return;
// ... reading-time logic
}
The early bailout costs nothing for pages the decorator does not apply to, so registering once for the whole site is cheap.
Auto-generate a table of contents from headings
$rendered = $sanitizer->markdown($event->page->content);
$toc = '';
if (preg_match_all('/<h([2-3])[^>]*id="([^"]+)"[^>]*>(.+?)<\/h\1>/u', $rendered, $m, PREG_SET_ORDER) > 0) {
$items = '';
foreach ($m as [, $level, $id, $text]) {
$indent = $level === '3' ? ' class="sub"' : '';
$items .= sprintf('<li%s><a href="#%s">%s</a></li>', $indent, $id, strip_tags($text));
}
$toc = '<nav class="page-toc"><ol>' . $items . '</ol></nav>';
}
$event->html = $toc . $rendered;
This pairs with Scriptor's heading-anchor renderer, which
already attaches id attributes to every <h2> / <h3>. The
regex is the simplest extraction; for nested or richer trees,
parse the HTML with DOMDocument instead.
Append an analytics or embed snippet at the bottom
$rendered = $sanitizer->markdown($event->page->content);
$event->html = $rendered . <<<'HTML'
<script defer data-domain="example.com" src="https://plausible.io/js/script.js"></script>
HTML;
A theme-level template change is usually cleaner for site-wide scripts; the decorator shape pays off when the snippet is content-dependent (different tracking per template, an embed only when a frontmatter flag is set, etc.).
See also
- Serve dynamic pages from PageResolving: for changing which Page resolves; this recipe is about changing the HTML after a Page resolved
- Replace 404 with a fallback handler:
the other event-driven recipe pair; both subscribe through the
same
PluginContext::subscribe()surface ContentRendering: the event surface, including the?string $htmlslot semantics and the "today only the original page is exposed" caveat this recipe works aroundSanitizer: the renderer the decorator re-runs to reproduce the default markdown pipelinePage: the readonly DTO the event hands the listener, withcontentandtemplateas the two fields this recipe reads- Concept: Frontend events: the four-event picture (PageResolving, PageResolved, ContentRendering, RouteNotFound) and how they relate