Defining a custom filter and sorter for Sculpin content types

Posted on by Matthias Noback

This blog runs on Sculpin, a static site generator. The generator itself runs on Symfony, which for me makes it easy to extend. However, I find that if you want something special, it can usually be done, but it may take several hours to get it right. In the end though, the solution is often quite elegant.

A custom content type for events

One custom feature I wanted for this website was a list of events (conference talks, trainings, etc.). Sculpin's documentation suggests using a custom content type for that. This allows you to create a directory with files, each of which will be considered an "event".

Setting up a custom content type is usually quite easy:

# in app/config/sculpin_kernel.yml

sculpin_content_types:
    events:
        permalink: event/:year/:month/:slug_title

The default configuration values that Sculpin calculates for this setup are good in this case (it will look in source/_events/ for the HTML or Markdown files describing the events; it will look for the _layouts/event.html file for the layout of each event's page, etc.). However, I wasn't interested in how "detail" pages were generated for each event; I just wanted a list of all events. The HTML for the events page would have to look something like this:

---
layout: default
title: Events
use: [events] # load the collection of "events"
---

<h2>Events</h2>

{% for event in data.events %}
    {{ event.content|raw }}
{% endfor %}

A sample event file would look something like this (front matter first, allowing some meta-data to be provided, then the content of the page):

# in source/_events/php-benelux-2020-workshop-decoupling-from-infrastructure.html

---
title: Decoupling from infrastructure
date: 2020-01-24
---

<p>Most application code freely mixes domain logic [...]</p>

This, again, just works great out-of-the-box.

Custom sorting

But now I wanted to change the sort order for the events. The default sorting is on date, descending. This doesn't feel like a natural ordering for upcoming events, which would show events far in the future first.

I didn't find a way to configure the sorting of events in app/config/sculpin_kernel.yml, so I looked at the source code of the SculpinContentTypesExtension class. I found out that the easiest thing to manually configure sorting would be to override the sorter service that Sculpin automatically defines for every content type:

services:

  # This sorter overrides the one Sculpin automatically configures for "events"
  sculpin_content_types.types.events.collection.sorter:
    class: Sculpin\Contrib\ProxySourceCollection\Sorter\MetaSorter
    arguments:
      - 'date'
      - 'desc' # I don't know why but "desc" works (I expected "asc")

The MetaSorter ships with Sculpin. I figured out the name of the service by reading through the code in SculpinContentTypesExtension. As you can see, I provide 'desc' as the second argument, even though I would expect the correct value to be 'asc'; 'desc' had the desired effect of showing the earliest events first.

Creating two filtered collections: upcoming and past events

I then realised it would be useful to have a list of upcoming events, sorted by date, ascending, and a list of past events, sorted by date, descending (oldest last). Upon regenerating the site, past events should automatically move to the list of past events. I figured I'd have to define two content types now: upcoming_events and past_events. These collections should both load the files in source/_events/, so the resulting configuration looks like this:

# in app/config/sculpin_kernel.yml

sculpin_content_types:
    upcoming_events:
        permalink: event/:year/:month/:slug_title
        path: event/
        layout: event
    past_events:
        permalink: event/:year/:month/:slug_title
        path: event/ # same path as "upcoming_events"
        layout: event

Which events end up in which collection should be determined by some kind of filter based on the current timestamp. Again, it turned out that Sculpin already has a built-in type for defining filters (FilterInterface), but it doesn't provide easy ways of setting it up for your custom content types.

The way I did it was write a compiler pass that modified Sculpin's own filter service definitions. First I created a custom SculpinKernel:

// in app/SculpinKernel.php

declare(strict_types=1);

use Sculpin\Bundle\SculpinBundle\HttpKernel\DefaultKernel;
use SculpinTools\CustomizationsBundle;

final class SculpinKernel extends DefaultKernel
{
    protected function getAdditionalSculpinBundles(): array
    {
        return [
            // Load my own bundle with customizations:
            CustomizationsBundle::class 
        ];
    }
}

Sculpin automatically picks up this class and uses it instead of its DefaultKernel.

Then I created that CustomizationsBundle:

declare(strict_types=1);

namespace SculpinTools;

use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\HttpKernel\Bundle\Bundle;

final class CustomizationsBundle extends Bundle
{
    public function build(ContainerBuilder $container)
    {
        /*
         * Register a compiler pass which should be able to modify 
         * Sculpin's service definitions:
         */
        $container->addCompilerPass(new AddEventsFilterPass());
    }
}

Finally, I created the AddEventsFilterPass compiler pass:

declare(strict_types=1);

namespace SculpinTools;

use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;

final class AddEventsFilterPass implements CompilerPassInterface
{
    public function process(ContainerBuilder $container)
    {
        foreach (['upcoming_events', 'past_events'] as $contentType) {
            $chainFilter = $container->getDefinition(
                'sculpin_content_types.types.' . $contentType . '.filter'
            );

            // Take the existing list of filters:
            $filters = $chainFilter->getArgument(0);
            // Add our own date filter to it:
            $filters[] = new Reference(
                'sculpin_content_types.types.' . $contentType . '.date_filter'
            );

            // Replace the existing list of filters with the new list:
            $chainFilter->replaceArgument(0, $filters);
        }
    }
}

Instead of overriding the services that Sculpin has defined earlier, the compiler modifies the existing service definitions. In this case, each content type filter will be a ChainFilter, consisting of multiple different filters. We have to add our own filter to it, and it also has to be defined as a service, so we can add it to the list of existing filters as a Reference. So before this code will work, we have to define those date filter services as well:

# in app/config/sculpin_services.yml

services:
  # ...

  # This filter will be registered by the AddEventsFilterPass
  sculpin_content_types.types.upcoming_events.date_filter:
    class: SculpinTools\DateFilter
    arguments:
      - 'date'
      - true

  # This filter will be registered by the AddEventsFilterPass
  sculpin_content_types.types.past_events.date_filter:
    class: SculpinTools\DateFilter
    arguments:
      - 'date'
      - false

With this setup, Sculpin is able to load the events, put them in two separate collections based on the date filter, and sort them in a natural way. Now we can use the past_events and upcoming_events collections inside the events.html.twig file:

---
layout: default
title: Upcoming events
use: [upcoming_events, past_events]
---

<h2>Upcoming events</h2>

{% for event in data.upcoming_events %}
    {% include "event.html.twig" with { event: event, upcoming: true } %}
{% endfor %}

<h2>Past events</h2>

{% for event in data.past_events %}
    {% include "event.html.twig" with { event: event, upcoming: false } %}
{% endfor %}

Since I want upcoming and past events to look (more or less) the same, I include a Twig file for each event, and set an extra variable to indicate that the event is or is not upcoming.

All of this works really well, as you can see on the Upcoming events page!

PHP Sculpin
Comments
This website uses MailComments: you can send your comments to this post by email. Read more about MailComments, including suggestions for writing your comments (in HTML or Markdown).