Tags and archive pages for blogs generated with Jigsaw

Posted on | by Nenad Živanović

While building my site using Jigsaw, a static site generator by the good people at Tighten, I enjoyed how easy it is to quickly build a static site utilizing the power of Laravel's Blade templating engine. I wanted to use the site for my personal blog, so I wanted features such as a list of posts by a tag and archive of posts by year, month and day, something that's quite common with blogs.

I started researching on how to do it and the only thing I found on the subject was a post by Alan Holmes on tagging a blog with Jigsaw. This wasn't quite what I was looking for, as I would have to manually create a page for each tag to show a list of posts with that tag.

Jigsaw event listeners

With Jigsaw, you can define event listeners to hook into the site build process. We need posts information from loaded Jigsaw collection to generate pseudo-collections. This information can be retrieved by adding a listener for afterCollections or afterBuild events. To accomplish the task at hand, we'll utilize the power of Jigsaw's remote collections

If we were to listen to afterBuild event, we would need to extract the needed information into a collection configuration array, add it to the site configuration and build the site again. Basically, on the second run, we utilize remote collections. For some reason rebuilding the site to generate additional pages doesn't feel right to me, especially if a similar feature needs to be added later. So, we'll listen to afterCollections event.

When collections are loaded, we can't just add our posts by tag collection as a configuration array; that step is finished and we need site data expected for the next step, which is building the site. We'll have to load that configuration array ourselves before we can proceed. Let's see how Jigsaw actually does that!

// vendor/tightenco/jigsaw/src/Jigsaw.php
public function build($env)
{
    ...

    $this->siteData = $this->dataLoader->loadSiteData($this->app->config);
    $this->fireEvent('beforeBuild');

    $this->remoteItemLoader->write($this->siteData->collections, $this->getSourcePath());
    $collectionData = $this->dataLoader->loadCollectionData($this->siteData, $this->getSourcePath());
    $this->siteData = $this->siteData->addCollectionData($collectionData);

    $this->fireEvent('afterCollections');

    ...
}

We can see that Jigsaw starts the build process by loading the configuration array and generating site data object. After that, the beforeBuild event is emitted. Collections from site data are used to generate temporary files in collection folder, under _tmp subfolder. In the process, collections data is extracted and it is added to the site data object. Collection data is now loaded.

Two dependencies are used to perform this part of the build: TightenCo\Jigsaw\Loaders\DataLoader and TightenCo\Jigsaw\Loaders\CollectionRemoteItemLoader. We can use them to do the same thing in our listener!

I'll create a listener class in the app/Listeners folder and call it AddTagsPages.

Note: Don't forget to edit composer.json and add the path to your listeners so they are autoloaded!

When attaching listeners to an event we can pass class name or a closure. Closures are usually easier to use when there is not much logic for the listener. In our case we'll have a bit more stuff to do, so we'll make a listener class that needs to have handle method. Both handle method and the closure receive instance of TightenCo\Jigsaw\Jigsaw as an argument. An important thing to note here is that the listener is not resolved through the container, it is simply instantiated. This means we can't simply give the class name when attaching listener. However, we can pass the closure and just instantiate the class ourselves and call the handle method passing the jigsaw object we got. This way we have control to inject dependencies in our listener. We can resolve those dependencies from the container since we have it available in the bootstrap.php.

// bootstrap.php
use App\Listeners\AddTagsPages;

$events->afterCollections(function ($jigsaw) use ($container) {
    $listener = new AddTagsPages($container[DataLoader::class], $container[RemoteLoader::class]);

    $listener->handle($jigsaw);
});

This is ok, but I prefer to bind the listener to the container and just resolve it in the closure

// bootstrap.php
use App\Listeners\AddTagsPages;
use TightenCo\Jigsaw\Loaders\DataLoader;
use TightenCo\Jigsaw\Loaders\CollectionRemoteItemLoader;

$container->bind(AddTagsPages::class, function ($c) {
    return new AddTagsPages($c[DataLoader::class], $c[RemoteLoader::class]);
});

$events->afterCollections(function ($jigsaw) use ($container) {
    $container->make(AddTagsPages::class)->handle($jigsaw);
});

Handling the event

We'll handle the event by generating new collection data and add it to the site data of the jigsaw instance.

// app/Listeners/AddTagsPages.php

/**
 * Handle `afterCollections` hook to add new collections based on data from existing ones.
 *
 * @param  \TightenCo\Jigsaw\Jigsaw  $jigsaw
 * @return void
 */
public function handle($jigsaw)
{
    $collectionData = $this->generateCollectionData($jigsaw);

    $jigsaw->getSiteData()->addCollectionData($collectionData);
}

Generating collection data is performed in a few steps. First, we'll set the jigsaw instance as listener property so we don't have to pass it around all the time. Then we'll append new collection configuration that we generate based on available data. We schedule a clean-up in order to remove _tmp folders (by attaching event listener for afterBuild event). Finally, we load the collection data by:

// app/Listeners/AddTagsPages.php

/**
 * Generate new collection with included new data.
 * @param  \TightenCo\Jigsaw\Jigsaw  $jigsaw
 * @return \Illuminate\Support\Collection
 */
protected function generateCollectionData($jigsaw)
{
    return $this->setJigsawInstance($jigsaw)
        ->appendNewCollectionsToConfigurations()
        ->scheduleCleanup()
        ->loadCollectionData();
}

/**
 * Add new collections data to jigsaw site data.
 *
 * @return $this
 */
protected function appendNewCollectionsToConfigurations()
{
    $collections = $this->jigsaw->app->config->get('collections');

    $this->appendNewCollectionsTo($collections);

    return $this;
}

/**
 * Append new collections to given old collections.
 *
 * @param  \Illuminate\Support\Collection  $collections
 * @return void
 */
protected function appendNewCollectionsTo($collections)
{
    $this->getCollectionsConfigurations()
        ->each(function ($collectionSettings, $collectionName) use ($collections) {
            $collections->put($collectionName, $collectionSettings);
        });
}

/**
 * Get new collections configurations.
 *
 * @return \Illuminate\Support\Collection
 */
protected function getCollectionsConfigurations()
{
    return collect([
        'posts_by_tag' => [
            'extends' => '_posts_by_tag._index',
            'path' => 'blog/tags/{tag}',
            'items' => $this->getTagItems(),
        ],
    ]);
}

/**
 * Get tag page metadata.
 *
 * @return \Illuminate\Support\Collection
 */
protected function getTagItems()
{
    return $this->getTags()->map(function ($tag) {
        return [
            'title' => strtoupper($tag),
            'tag' => $tag,
        ];
    });
}

/**
 * Get all dates when we have posts in form of array with year, month and day.
 *
 * @return \Illuminate\Support\Collection
 */
protected function getTags()
{
    return $this->jigsaw->getCollection('posts')
        ->flatMap->tags
        ->filter()
        ->unique()
        ->values()
        ->toBase();
}

/**
 * Generate collections data for remote collections.
 *
 * @return $this
 */
protected function loadCollectionData()
{
    $siteData = $this->loadSiteData();

    $this->writeTempSiteData($siteData);

    return $this->dataLoader->loadCollectionData($siteData, $this->jigsaw->getSourcePath());
}

/**
 * Load site data with added configurations.
 *
 * @return \TightenCo\Jigsaw\SiteData
 */
protected function loadSiteData()
{
    return $this->dataLoader->loadSiteData($this->jigsaw->app->config);
}

/**
 * Write temporary collection pages.
 *
 * @param  \TightenCo\Jigsaw\SiteData  $siteData
 * @return void
 */
protected function writeTempSiteData($siteData)
{
    $this->remoteItemLoader->write($siteData->collections, $this->jigsaw->getSourcePath());
}

In getCollectionConfigurations method we've defined our posts_by_tag collection that will generate page for each tag. Each page will list all posts with that tag set in tags property in front-matter of the post. The template used for these pages can be like this (note: these templates use tailwindcss for styling):

@extends('_layouts.page')

@section('pageContent')
    <h1 class="text-4xl md:text-5xl lg:text-6xl font-normal">{{ $page->tag }}</h1>
    <main class="mt-6 sm:mt-12 text-lg antialiased leading-normal" role="main">
        @each('_partials.post', $page->getPostsByTag($posts), 'post')
    </main>
@endsection

Helper method getPostsByTag is defined in the configuration file as:

// config.php
...

'getPostsByTag' => function ($page, $posts) {
    return $posts->filter(function ($post) use ($page) {
        return in_array($page->tag, $post->tags ?? []);
    });
},

...

Since the tag is present in page metadata, we can use it to filter the posts and get only those that have that tag.

Archive by year, month and day

Most of the logic can be reused if we wanted to get lists of posts for a year, month and day. The only things that need to be different are the collections that we need to add to the configuration, helper methods to filter posts by metadata available for the page and templates that call the helper methods. This means we can extract everything from our listener except the getCollectionConfigurations method to an abstract class. Now we can create AddArchivePages listener that extends this abstract class and implements it's own getCollectionConfigurations method.

The rest is done in a similar manner... We add posts_by_year collection by getting all the years when posts are published, We add another collection with the combination of year and month, and the final collection with the combination of year, month and day. In my use case, where posts routes are organized as /blog/{year}/{month}/{day}/{slug}, it makes sense to have pages with those lists of posts.

Since this post is already longer than I originally intended, there's not going to write a detailed explanation on how to do it. Instead here's a link to a gist with our listeners, templates, helpers and the rest needed for this to work, except templates for layouts and partials.

A bit of refactoring

It seemed like a good idea to move the logic required to register the listener into the listener itself and just call a static method in the bootstrap file

// bootstrap.php
use App\Listeners\AddTagsPages;
use App\Listeners\AddArchivePages;

AddTagsPages::register($container);
AddArchivePages::register($container);

Also, since I'm doing that, what about moving page helper methods into the listeners that need them as well? We'll have a bit cleaner config file, and if we don't want the functionality provided by one listener, we can just remove one line in the bootstrap.php file and remove the listener class.

Some final thoughts

This was a great learning experience for me as I got the opportunity to dive into Jigsaw's code a bit. Hopefully, you'll find it interesting and useful. Thank you for reading!

If you have a followup question or remark, I'd love to hear from you! You can reach me on Twitter.