Jackdaw's Nest

Custom Content Processors in Stenope

Customizing the HTML generated from Markdown files

August 22, 2023

UPDATE November 2024! I've moved on to using another static site generator for this site and some information in this article is out of date. I've left the rest of the article intact just in case you might find it useful.


This short guide will show you how to customize the html output of your markdown files with Stenope.

What is Stenope?

Stenope is a static website builder built on top of Symfony framework.

Compared to most static website generators, Stenope has a rather unique approach to building websites. Stenope allows you to build your site as you would any regular Symfony based site, and Stenope will crawl your site and dump out the static HTML files. It's relly nice and super flexible.

Stenope Built In Content Processors

Stenope comes with a bunch of content processors and support for different content types. Markdown is my favourite editing format and it's supported out of the box.

Behind the scenes Stenope uses the superfast Parsedown implementation of markdown. Stenope also comes with a bunch of builtin processors that can be used to do cool things like making sure that external links always open in a new browser window.

Configuring the Processors

There are a few processors that Stenope comes with that are enabled by default. For example, there's a processor that adds target="_blank" to all external links on your site. Personally, I don't like forcing people to open external links in new tabs or windows, so I wanted to disable this processor.

Configuring the processors is a relatively new feature and it looks like Stenope's documentation is slightly lacking in this regard. Luckily, everything is built with Symfony standards and digging the code and configuration is relatively easy.

It turns out that the processor responsible for external links is called HtmlExternalLinksProcessor. Here's how I disabled it with a simple configuration change:

// config/packages/stenope.yaml
stenope:
    processors:
        external_links:
            enabled: false

And that's it. Now there won't be target="_blank" on every external link on your site.

If you look at the html that Stenope produces, you will notice that every heading element has a new anchor tag inside the heading. This isn't standard markdown behaviour, and I wanted to disable this as well. I had a plan to create my own processor to add slightly different anchors in headings.

This is what my config file looks like with the anchors processor dsabled as well.

// config/packages/stenope.yaml
stenope:
    processors:
        external_links:
            enabled: false
        anchors:
            enabled: false

Creating a New Processor

I wanted to add anchors to all h2 to headings, to make it easier to link to an individual post in the blog section of this site. This was pretty easy thanks to the builtin anchors processor that Stenope comes with.

Here's my custom anchors processor

<?php
namespace App\Processor;

use Stenope\Bundle\Behaviour\HtmlCrawlerManagerInterface;
use Stenope\Bundle\Behaviour\ProcessorInterface;
use Stenope\Bundle\Content;

class HeaderProcessor implements ProcessorInterface
{
    private HtmlCrawlerManagerInterface $crawlers;
    private string $property;
    private string $selector;

    public function __construct(
        HtmlCrawlerManagerInterface $crawlers,
        string $property = 'content',
        string $selector = 'h1, h2, h3, h4, h5'
    ) {
        $this->crawlers = $crawlers;
        $this->property = $property;
        $this->selector = $selector;
    }

    public function __invoke(array &$data, Content $content): void
    {
        if (!isset($data[$this->property])) {
            return;
        }

        $crawler = $this->crawlers->get($content, $data, $this->property);

        if (!$crawler) {
            return;
        }

        foreach ($crawler->filter($this->selector) as $element) {
            $this->addAnchor($element);
        }

        $this->crawlers->save($content, $data, $this->property);
    }

    /**
    * Add anchor
    */
    private function addAnchor(\DOMElement $element): void
    {
        $child = $element->ownerDocument->createDocumentFragment();

        if (!$id = $element->getAttribute('id')) {
            return;
        }

        $child->appendXML(sprintf('<a href="#%s" class="anchor">#</a>', $id));

        $element->prepend($child);
    }
}

Here's the original file where I copied this from. My processor has just a small change in the addAnchor method.

Now we still need to configure this service to be picked up by Stenope. That's easy to do with Symfony services and service tagging.

Here's how to register the new service:

// config/services.yaml
services:
    App\Processor\HeaderProcessor:
        arguments:
            '$selector': 'h2'
        tags: 
        - { name: 'stenope.processor', priority: -100 }

The key here is the tag stenope.processor. That way Stenope will pick up this service and run the processor through it. Also, I wanted to run my custom anchor processor only on h2 elements, and that's why I configured the $selector parameter.

Priority is completely optional and not needed in most cases. However, I wanted this processor to run last after Stenope has run its own builtin processors. The reason is, that Stenope had a separate processor that adds ID's to all headings. These ID's need to be present for the anchor generation to work.