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 !== null early return is the cooperation convention. The bundled scriptor-markdown-pages plugin 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 $html slot semantics and the "today only the original page is exposed" caveat this recipe works around
  • Sanitizer: the renderer the decorator re-runs to reproduce the default markdown pipeline
  • Page: the readonly DTO the event hands the listener, with content and template as the two fields this recipe reads
  • Concept: Frontend events: the four-event picture (PageResolving, PageResolved, ContentRendering, RouteNotFound) and how they relate