An extremely picky developer's take on static site generators for PHP: Part 2 - Jigsaw

In the first article of the series we took a look at Sculpin, the PHP static site generator which is currently the most starred on Github. Today we’re exploring Jigsaw, a tool which promises to bring a Laravel-based approach to the world of PHP static site generators (SSGs).

I’m gonna be honest with you—I’m not the biggest Laravel user. I appreciate what it's done for PHP, and how it has helped push forward the world of PHP development in a time where older frameworks were struggling to keep up with innovations coming from other platforms. It’s also a prime example of the good kind of cross pollination happening in the PHP world: it employs packages from the Symfony world, The PHP League, Doctrine, and more. It’s a good lesson in building something cool on top of great foundations, without having to continuously reinvent the wheel. Overall, I think that Laravel is an excellent framework, but I'm definitely inexperienced with it. If you think I have overlooked or misjudged something (both Laravel and Jigsaw-related), please feel free to reach out to me on Twitter: constructive criticism is always welcome!

Jigsaw (~900★)

Just like I did with Sculpin, I’ll jump straight into the action, so let’s create a sample project and play with it! We’ll open the official docs and start from there.

Unlike Sculpin, Jigsaw doesn’t provide a skeleton project we can use as quickstart. Instead, we must create a normal project, add tightenco/jigsaw as dependency, and use its CLI:

1
2
3
mkdir jigsaw-example && cd jigsaw-example
composer require tightenco/jigsaw
./vendor/bin/jigsaw init

Let’s go through the default directory structure that was just generated. In the top level directory, we have a config.php with this code:

1
2
3
4
5
6
7
<?php

return [
    'baseUrl' => '',
    'production' => false,
    'collections' => [],
];

I’m not sure yet what this means, but having a configuration file clearly placed seems like a good approach 👍. The second PHP file is called bootstrap.php, and it’s empty (only some commented-out code), but it hints at how we could use the event system at some point.

We also have Javascript dependencies. I see a webpack.mix.js, which lets me know that Laravel Mix is being used. So I run npm install and in the meantime I’ll explore the docs to try to understand a bit more of what I’m doing 😉.

About 10 dependencies and 300MB in node_modules later, we’re back. Now, let’s try to build our site. It appears we have 2 ways of doing so: we can use the Jigsaw CLI to build and then serve, or use npm and its live reload capabilities provided by Browsersync:

1
2
3
4
5
6
7
# With Jigsaw CLI
./vendor/bin/jigsaw build
./vendor/bin/jigsaw serve # localhost:8000


# With npm
npm run watch # localhost:3000

Regardless, both work and you get this!

Website built

Naturally, only John Oliver can really convey my reaction to this page.

The site works

The directory structure

Now let’s explore the skeleton project that was generated earlier. The order in which we’re exploring parts of the project might appear to be random because, well, it is. That’s the cool thing about discovering something new, right? The exploratory phase, where you peek at this, take a look at that, and oh, a source directory! What lies therein?

Directory structure

Being a Laravel-based project, I’m not surprised to find an index.blade.php file, which contains the shiny Hello world! from above. The @extends('_layouts.master') directive hints to me that I should go look in the _layouts directory for a master.blade.php template, and lo and behold, there it is. Everything seems nice and tidy with the templates, let’s go check how assets are handled.

Assets and configuration

Laravel Mix provides out of the box support for Sass, so source/_assets/sass/main.scss is your starting point. SCSS might not be the latest and shiniest thing on the CSS landscape, but it’s battle-tested and definitely gets the job done, so I approve of its inclusion. Let’s turn that page into something that will get your attention

1
2
3
4
5
6
/* source/_assets/sass/main.scss */
$bg-color: red;

body {
  background-color: $bg-color;
}

Let’s hit save and webpack should do its magic and turn our background red, right? Yes, it does! I'll spare you the screenshot for your eyes' sake, and now let's continue our exploration.

I’d like to set up a basic blog, with posts written in Markdown files. To do that, let’s introduce the concept of collections, which, as the docs state, give you the ability to access your content at an aggregate level. Sounds about right for a blog. Remember the config.php file from earlier? Let’s tweak it to tell Jigsaw about our collection of blog posts:

1
2
3
4
5
6
7
8
9
10
11
<?php

return [
    'baseUrl' => '',
    'production' => false,
    'collections' => [
        'posts' => [
            'path' => 'blog/{date|Y-m-d}/{filename}',
        ],
    ],
];

Jigsaw will look for the corresponding directory for every collection we define. In our example, posts will become source/_posts, so in that directory let’s create a blog post to show our love for one of the greatest songs of all times.

1
2
3
4
5
6
7
8
9
10
11
12
13
---
extends: _layouts.post
title: The Fresh Prince
author: Will Smith
date: 1990-09-10
section: content
---

Now this is a story all about how
My life got flipped-turned upside down
And I'd like to take a minute
Just sit right there
I'll tell you how I became the prince of a town called Bel-Air

Let’s save this file as the-fresh-prince.md, and then let’s create the appropriate template as specified in the extends part of the front matter: source/_layouts/post.blade.php.

1
2
3
4
5
6
7
8
@extends('_layouts.master')

@section('body')
<h1>{{ $page->title }}</h1>
    <p>By {{ $page->author }} - {{ date('F j, Y', $page->date) }}</p>

    @yield('content')
@endsection

Let’s run ./vendor/bin/jigsaw build, and we’ll have a nice build_local directory generated with our shiny website, so let’s open the URL (which we still have to build ourselves) and… It works!

(Author’s note: at this point I would like to add more GIFs, but I have to keep it professional. Let’s all just pretend to have a GIF of Jake Peralta saying noice, thank you.)

Is Jigsaw Contentful-ready?

We’ve covered some of the basics, so now the million-dollar question: is Jigsaw Contentful-ready? With this, of course, I mean how easily content stored in Contentful can be integrated in a Jigsaw-powered website. We open the documentation page about event listeners, and with that page open in the tab, we can give this a try. Remember bootstrap.php? It contained some commented-out code which hinted at how events work, so let’s reopen that:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?php

use TightenCo\Jigsaw\Jigsaw;

/** @var $container \Illuminate\Container\Container */
/** @var $events \TightenCo\Jigsaw\Events\EventBus */

/**
* You can run custom code at different stages of the build process by
* listening to the 'beforeBuild', 'afterCollections', and 'afterBuild' events.
*
* For example:
*
* $events->beforeBuild(function (Jigsaw $jigsaw) {
*     // Your code here
* });
*/

Ok, seems to be reasonably easy. As extra help, after some googling I found an article about creating a sitemap with Jigsaw, and the original pull request for the events feature, so I have enough to figure this out. Let’s composer require contentful/contentful and hack this thing together!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
<?php

// In bootstrap.php

require_once 'contentful_fetcher.php';
$events->beforeBuild(ContentfulFetcher::class);


// In contentful_fetcher.php

use Contentful\Delivery\Client;
use Contentful\Delivery\Resource\Entry;
use TightenCo\Jigsaw\Jigsaw;

class ContentfulFetcher
{
    /**
     * Jigsaw requires either a callable or the name of
     * a class which implements a method called "handle"
     *
     * @param Jigsaw $jigsaw
     */
    public function handle(Jigsaw $jigsaw): void
    {
       // Let's only build for production
        if ($jigsaw->getEnvironment() === 'local') {
            return;
        }

        $client = $this->getClient($jigsaw);

        foreach ($jigsaw->getCollections() as $collection) {
            $this->cleanSourceDir($jigsaw, $collection);
            $entries = $this->getPlainEntries($client, $collection);

            foreach ($entries as $entry) {
                $this->writeEntryToMarkdownFile($jigsaw, $entry, $collection);
            }
        }
    }

    private function getClient(Jigsaw $jigsaw): Client
    {
        return new Client(
            $jigsaw->getConfig('contentful.accessToken'),
            $jigsaw->getConfig('contentful.spaceId')
        );
     }
    
    /**
     * Before every run, we remove the previous contents of the source directory.
     */
    private function cleanSourceDir(Jigsaw $jigsaw, string $collection): void
    {
        $dir = $jigsaw->getSourcePath().'/_'.$collection;
        $jigsaw->getFilesystem()->deleteDirectory($dir, true);
    }

    /**
     * We transform objects of type Contentful\Delivery\Resource\Entry
     * into plain arrays for ease of use later on.
     */
    private function getPlainEntries(Client $client, string $collection): array
    {
        // Convention:
        // Every defined collection corresponds to a content type ID in Contentful
        $query = (new Contentful\Delivery\Query())
            ->setContentType($collection)
        ;
        $entries = $client->getEntries($query);

        if (!count($entries)) {
            return [];
        }

        /** @var Contentful\Delivery\Resource\ContentType $contentType */
        $contentType = $entries[0]->getContentType();

        return \array_map(function (Entry $entry) use ($contentType) {
            // Convention:
            // The content types may contain a field called body,
            // which will be used as the markdown content of the generated file,
            // so we give it a default value
            $plainEntry = [
                'id' => $entry->getId(),
                'body' => '',
            ];
            foreach ($contentType->getFields() as $field) {
                $plainEntry[$field->getId()] = $entry->get($field->getId());
            }
    
            return $plainEntry;
        }, $entries->getItems());
    }
    
    /**
     * Converts an entry into a markdown file and writes it to disk.
     */
    private function writeEntryToMarkdownFile(Jigsaw $jigsaw, array $entry, string $collection): void
    {
        $contents = '---
extends: _layouts.'.$collection.'
section: content
';
        $body = '';
        foreach ($entry as $field => $value) {
            if ($field === 'body') {
                $body = $value;

                continue;
            }
            $contents .= $field.': '.$value."\n";
        }

        $contents .= '---'."\n\n".$body;

        $path = sprintf('_%s/%s.md', $collection, $entry['id']);
        $jigsaw->writeSourceFile($path, $contents);
    }
}

This code is more of a proof of concept than complete implementation (it relies a lot on conventions, and it lacks handling of special fields such as locations, links, etc); however, it should get the job done for a basic example.

First of all, we pass a fully-qualified class name (FQCN) to $events->beforeBuild(), and Jigsaw will know that it must create an instance of that class and execute the handle method of it. Truth be told, I think there should be an interface in order to have a stronger contract, something like this:

1
2
3
4
5
6
7
8
9
10
<?php

namespace TightenCo\Jigsaw;

use TightenCo\Jigsaw\Jigsaw;

interface EventHandlerInterface
{
    public function handle(Jigsaw $jigsaw);
}

Or perhaps just requiring any valid callable (thus including objects implementing __invoke). It works now in its present state but I like strong contracts, and you should know by now that I'm picky—it's in the title of the series!

This is an overview of what ContentfulFetcher actually does:

  • If the local environment is detected, the whole process will be stopped. This is done to avoid the performance impact due to having to fetch all data from Contentful on every build (which can likely be very often, if running on watch mode). The idea is to run a ./vendor/bin/jigsaw build production initially to fetch all content, and then operate with what’s been stored locally. Of course, a good idea would be to add the generated files to the local .gitignore, to avoid keeping them in version control (content doesn't belong there).
  • We create an instance of a client object using the Contentful Delivery SDK for PHP.
  • We iterate through all the defined collections, and we use a convention to map a local collection to a content type ID in Contentful. We then clean the corresponding directory, where we will dump the generated files.
  • We fetch entries for the given content type, and we use the Contentful\Delivery\Resource\ContentType object to get a list of fields. With those fields, we build the appropriate YAML front matter section, and then we add the body at the end (string handling here is very basic and could be improved).
  • We finally dump the contents of the generated file. We use the Contentful ID to save them, as we can define a pretty URL when configuring the collection.

There’s a lot of gluing going on, but it works on my machine™️. This could be done a thousand times better, with more flexible configuration, but as a proof of concept I’m reasonably satisfied with it.

Verdict

Before our final verdict, I think it's only fair to mention that during the process of writing this article, I experienced a couple of issues which have since been fixed. Before publishing, I got in touch with Matt Stauffer (one of the people behind Jigsaw) and explained that I was experiencing some difficulties with the handling of assets and events (misconfiguration for the former, and lack of docs for the latter). Over the course of a weekend, both issues have been fixed 👏

I quite like Jigsaw, but it’s rather barebones in its out-of-the-box configuration. Unlike Sculpin, it makes no assumption over what kind of use you’ll have for it, which is a double-edged sword: it avoids clutter and gives you complete control, but it also creates a higher barrier of entry for simple use cases such as blog.

To solve this issue, Matt revealed to me that they’re building a generator with website skeletons, to address the most common needs (such as a blog, a documentation website, etc), with a Github issue to track progress. He also mentioned that they just launched a new showcase of websites built with Jigsaw, which I encourage you to check out.

As a non-Laravel developer, I was afraid that Jigsaw would be difficult to pick up and use, but it definitely wasn’t the case. It is actively being developed (the events system was added just a few months ago) and is on track to quickly become the most popular PHP static site generator, in terms of stars on Github. It provides very good defaults, and assets are preconfigured to use a modern stack (Sass for CSS, and webpack with Babel for JS).

The only real criticisms I can make are actually the same as when I discussed Sculpin: content and presentation (assets and templates) could be more separate; instead, they belong to the same source directory, and I would love to be able to have a more defined structure for collections, instead of relying exclusively on the YAML bit before the actual Markdown contents.

Next article will be about Couscous, which describes itself as a documentation generator. Stay tuned!

Blog posts in your inbox

Subscribe to receive most important updates. We send emails once a month.