Adventures in Grav Theme Development: Building a Loosely-Coupled Theme

Grav CMS claims that its themes are tightly-coupled. It does not have to be.

By Ace Z. Alba

Published on January 5, 2023, 2:40 pm

Category: grav

Tag: theme development, twig

I found and fell in love with Grav a long time ago. It uses markdown, it does not use a database. Drop it into a shared server, you have a website. However, I found the ecosystem to be a bit broken. This comes from Grav themes being tightly coupled. This is according to the Grav documentation:

Because of the tight coupling with Grav pages and themes, a Grav Theme is an integral and very important part of a Grav site. By this we mean that each Grav page references a template in the theme, so your theme needs to provide the appropriate Twig templates that your pages are using.

Because of this tight coupling, themes aren't wearable. You can't just change your site's look as if it were a dress, as if it were WordPress. Every theme uploaded to the Grav listing is too idiosyncratic for normal, non-techie users. Grav's remedy for this is the skeleton, which allows a developer to package a demo Grav site complete with the plugin dependencies, and demo pages. As I soon found out, this does not remedy anything, and it only adds more incompatible settings among themes.

This does not have to be the case. In fact, Grav already had the features to allow for loosely-coupled themes that can be worn on and switched around, it only needed to enforce it the way Wordpress does. You do not have to upload a theme and upload a skeleton for your users and clients to make your product function.

So how to make a loosely-coupled, wearable theme?

Two things.

One, is a substitute for the skeleton.

Grav has a blueprints.yaml which allows you to declare the plugins you need to make it work under the dependencies metadata.

Grav also allows you to bundle a _demo\ folder which allows you to load demo pages into your Grav installation. Hypertext implements this well.

Both of them come together especially well when installing either from the CLI through the Grav Package Manager and the admin plugin.

Granted, you may want a skeleton if your theme can only be installed manually. But if your theme is already approved for the Grav Package Manager, there is no stopping you from integrating these details into your theme as well.

Two, is overriding the default template.

This is where the magic happens, and building this carefully should allow your themes to be switchable and help your clients migrate to your Grav products with ease.

You see, in the absence of a template, the system uses a fallback default template. Two to be exact, one for pages, and another for modular pages. However, this default only spits out an error that your page template could not be found.

Adding default.html.twig to your theme allows you to override this fallback behavior. In the instance that Grav fails to find your page template, it will use whatever behavior you coded into your theme's default template. default is usually coded to spit out the page content. But you can code it further to catch everything that your theme could not support. Which is easy, as for GravCMS, a page is either a page or a page collection/listing (i.e. a page with content.item frontmatter).

Here's what you need to do:

As of writing, I have not yet merged Canvas' implementation of this fallback override. But for purposes of documentation and future reference, I am going to share it here.

Canvas' default template embeds its partials/base.html.twig, which then relies on a partials/content.html.twig, which runs the following logic:

{# partials/content.html.twig, used in partials/base.html.twig and default.html.twig #}
{% if page.template() not in modular_alias and
page.header.content.items == '@self.modular' %}
{# make all modular pages with the proper header
behave like modular pages #}
    {% for module in page.collection() %}
        {% include 'modular/default.html.twig' %}    
    {% endfor %} 
{% elseif page.template() not in blog_alias and
  page.header.content.items %}
{# Collection Compatibility Layer
Dump a list of pages if page has page.content definition
#}
  {% set content = page.content|slice(page.summary|length) %}
  {{ content|raw }}
  {% include 'partials/loop.html.twig' %}
{% elseif (page.header.summary.enabled or page.header.summary.enabled is null) and 
      (enable_subtitle is null or enable_subtitle) and
      (page.template() not in item_alias ) and (not page.home())%}
{# Handle proper content slicing
of summary and body text #}
  {% set content = page.content|slice(page.summary|length) %}
  {{ content|raw }}
{% else %}
  {{ page.content|raw }}
{% endif %}

In my experiments, I've found that modular/default.html.twig is a bit quirky. so I had to create basic support for the modular template as well. As you can see below, its logic is very simple, as it is only intended to output the content of modules in cases where the user has a modular page template.

{# modular.html.twig #}

{% embed 'partials/base.html.twig' %}
{% block content %}
    {% for module in page.collection() %}
        {% include 'modular/default.html.twig' %}    
    {% endfor %} 
{% endblock content %}
{% endembed %}

The following is also defined in modular/default.html.twig:

{# modular/default.html.twig #}

<section>
    {% if module.content()|raw %}
        {{ module.content()|raw }}
    {% elseif page.content()|raw %}
        {{ page.content()|raw }}
    {% endif %}
</section>

This is in reference to a weird bug I've found specifically for this behavior I wanted. In the case of modules, page.content()|raw only works as intended if and only if module.content()|raw is also used in the page. In the above code, the output actually comes from page.content()|raw, but it will not work alone.

The code above probably needs some refactor, but so far it works the way I wanted. I have it tested with a couple of random pages and demo content from a couple of skeletons, and it successfully renders their content without fail. Not their original styles, just their content, which is just what is intended.