Level Up Your Twiggery

walking up stairs

Drupal 8’s templating language, Twig, provides a powerful suite of tools that often go underutilized. Learning when and where to apply them will empower you to create more readable and DRY code, and allow themers of all experience levels to contribute in a maintainable way. Specifically, we'll review:

  1. Determining if a field is empty
  2. The |as filter
  3. Override templates without duplicating markup
  4. More flexibility with |value and |label filters

Determining if a field is empty

To highlight these key concepts, let us consider a simple example—wrapping the Article node’s body field in a <details> element. We start with this simple tweak to node--article.html.twig.

<details>
  <summary>More Information</summary>
  {{ content.body }}
</details>
{{ content|without('body') }}

But QA kicks it back, noting that the body field is not required and an empty <details> element shows on the page when the field is empty. Let’s add a condition.

{% if content.body is not empty %}
  <details>
    <summary>More Information</summary>
    {{ content.body }}
  </details>
{% endif %}

But this doesn’t do the trick; the details element still shows when the WYSIWYG is empty. What’s going on? Inspect the variable with {{ dump(content.body) }}

array:2 [▼
  "#cache" => array:3 [▼
    "contexts" => []
    "tags" => []
    "max-age" => -1
  ]
  "#weight" => 0
]

You see, even though the body field is empty, its render array is not, because Drupal’s rendering system injects cache-ability information. One way around this is to check for individual field items in the render array, i.e., {% if content.body.0 %}. This is pretty good, but there are some caveats to consider:

  1. This only works with field render arrays and other elements whose contents live inside children with numbered keys.
  2. Might a preprocessor or alter hook muck with the render array contents, doing something unexpected like removing the first item (index 0) but leaving the others (indices 1+)? This is unlikely but possible!
  3. Similar to #2, some caching mechanism might produce a render array with the markup already generated rather than a typical field element with a variety of items.

That’s why I prefer manually rendering fields as the most fool-proof way to determine whether they’re empty. We can accomplish this easily with the |render Twig filter, and for good measure, let’s add |trim to remove any whitespace in the empty-check.

{% if content.body|render|trim is not empty %}
  <details>
    <summary>More Information</summary>
    {{ content.body }}
  </details>
{% endif %}

That’s pretty good! It could be better, though. Can you spot the inefficiency? We’re rendering content.body twice. Once in the condition, and again in the Twig print statement. The improvement is to assign the results of the manual render to a variable and then print out the rendered string.

{% set body_markup = content.body|render %}
{% if body_markup|trim is not empty %}
  <details>
    <summary>More Information</summary>
    {{ body_markup }}
  </details>
{% endif %}

We’re 90% of the way there. The above snippet may still show an empty <details> element when the field only contains whitespace. “But that’s not possible” you astutely protest, “because we trim whitespace in the empty check!” Indeed, but for those following along at home, see for yourself: Click the CKEditor “Source” button in your WYSIWIG field and insert some whitespace with no markup. Now, if you have Twig debugging turned on, you’ll see something like this in the source.

<details>
  <summary>More Information</summary>
  <!-- THEME DEBUG -->
  <!-- THEME HOOK: 'field' -->
  <!-- FILE NAME SUGGESTIONS:
      * field--node--body--article.html.twig
      * field--node--body.html.twig
      * field--node--article.html.twig
      * field--body.html.twig
      * field--text-with-summary.html.twig
      x field.html.twig
  -->
  <!-- BEGIN OUTPUT from 'themes/my_theme/templates/field.html.twig' -->
  
  <!-- END OUTPUT from 'themes/my_theme/templates/field.html.twig' -->
</details>

If the field were empty, its render array would contain no elements, so Twig’s debug mode would not print any HTML comments. But longtext fields do not ignore whitespace when determining if their items are empty, so Drupal accordingly populates the field’s render array and adds the HTML comments to the rendered body field output. And this causes our “if body_markup is not empty" condition to pass with flying colors.

How can we best proceed? You may be tempted to filter the variable with |striptags(), which removes all markup (including the HTML comments) except a specific set of whitelisted tags, e.g., body_markup|striptags('<p><a><em><strong>'). But hard-coding the list of “allowed tags” is not ideal, as we’ll need to remember to come back and update it if the text format configuration ever changes in the future. Another important consideration is caching: Drupal’s auto-placeholdering system uses the <drupal-render-placeholder> tag to insert content that it has previously rendered and cached. So, if you do go this route, don’t forget to whitelist that tag, as well.

I prefer using the |remove_html_comments filter. Drupal.org issue #2672656 provides a core patch to add the filter, but as you can see, the issue is not active, so you may prefer to copy/paste the code from that patch into a custom Twig filter (see the next section for how-to). Either way, the final template code looks like this.

{% set body_markup = content.body|render %}
{% if body_markup|remove_html_comments|trim is not empty %}
  <details>
    <summary>More Information</summary>
    {{ body_markup }}
  </details>
{% endif %}

Voila! Wasn’t that simple? No, not really. The empty-checking logic is quite cumbersome. Can we make things easier?

The |as filter

To empower front-end developers to apply various Twig templates to a field, and at the same time remove the need for empty checking, let’s make a filter that adds template suggestions to a render array. To get started, you'll need a custom module with a PHP class in its src directory that extends \Twig_Extension. Additionally, the module needs a service file that registers that class as a service and tags it with twig.extension.

Here’s the class file with the |as filter implemented.

/**
 * Creates custom Twig filters and functions.
 */
class MyTwigExtension extends \Twig_Extension {

  /**
   * {@inheritdoc}
   */
  public function getName() {
    return 'my_twig_extension';
  }

  /**
   * {@inheritdoc}
   */
  public function getFilters() {
    return [
      new \Twig_SimpleFilter('as', [$this, 'suggestThemeHook']),
    ];
  }

  /**
   * Adds a theme suggestion to the element.
   *
   * @param array|null $element
   *   An element render array.
   * @param string $suggestion
   *   The theme suggestion, without the base theme hook.
   *
   * @return array
   *   The element with the theme suggestion added as the highest priority.
   *
   * @throws \Exception
   */
  public static function suggestThemeHook($element, $suggestion) {
    // Ignore empty render arrays (e.g. empty fields with #cache and #weight).
    if (!empty($element['#theme'])) {
      // Transform the theme hook to a format that supports multiple suggestions.
      if (!is_iterable($element['#theme'])) {
        $element['#theme'] = [
          $element['#theme'],
        ];
      }
      // The last item in the list of theme hooks has the lowest priority; assume
      // it's the "base" theme hook.
      $base_theme_hook = end($element['#theme']);
      // Add the suggestion to the front.
      array_unshift($element['#theme'], "{$base_theme_hook}__$suggestion");
    }
    return $element;
  }

}

With this, the Twig code for wrapping the body field in a <details> element can be shortened to just {{ content.body|as('details') }}, along with a new template, field--details.html.twig, that adds the <details> markup. “How is this different/better” you might be asking “than just using the standard field-name template suggestion, field--node--body--article?” Three main reasons:

  1. Naming the template in a generic way leaves it available for use by other fields. If you have another field that needs the same customization, you no longer have to resort to various forms of chicanery, like copy/pasting the original template into an exact but renamed duplicate, Twig extendsing an otherwise-unrelated file, or symlinking one template to another.
  2. For experienced Drupal developers, the idea of template suggestions and their naming scheme might seem obvious. But to an outsider reviewing the Article’s node template, the source of the <details> wrapper around the body field may prove arcane. The addition of |as('details') makes the code more readable and adds semantic meaning to the source.
  3. Keeps front-end logic in the front-end. Normally, you would implement hook_theme_suggestions_alter() in a theme or module file to make unrelated fields use the same template. While there’s nothing wrong with this approach, it creates a dependency on PHP skills for any developer wishing to work on the front-end. The |as filter allows front-end developers to stay out of the PHP if they want to.

Let’s now look at how to implement the new field--details template suggestion efficiently.

Override templates without duplicating markup

Creating a theme template typically goes like this:

  1. Inspect theme debug output to find:
    • Which template is currently being used.
    • Naming suggestions for the new template.
  2. Copy/paste the original template to your theme’s templates directory.
  3. Rename the file to match the template suggestion.
  4. Make your desired changes to the template.

There are three problems with this process:

  1. It usually results in significant code duplication between the original template and the override.
  2. Depending on the changes made to the override, it may lose contextual wrappers (the “gear icon”) and attributes data injected by other modules.
  3. Future changes to the original template usually go unnoticed rather than being evaluated for applicability and ported into the override.

Let’s find a better way! Through judicious use of Twig "blocks”, we can mitigate the first two issues entirely and reduce the impact of the third. Start by copying the core field template into your custom theme’s templates directory. Below, I’ve started with core/modules/system/templates/field.html.twig and refactored it to move the wrapping div outside all the conditions.

<div{{ attributes }}>
  {% if label_hidden %}
    {% if multiple %}
      {% for item in items %}
        <div{{ item.attributes }}>{{ item.content }}</div>
      {% endfor %}
    {% else %}
      {% for item in items %}
        {{ item.content }}
      {% endfor %}
    {% endif %}
  {% else %}
    <div{{ title_attributes.addClass(title_classes) }}>{{ label }}</div>
    {% if multiple %}
      <div>
    {% endif %}
    {% for item in items %}
      <div{{ item.attributes }}>{{ item.content }}</div>
    {% endfor %}
    {% if multiple %}
      </div>
    {% endif %}
  {% endif %}
</div>

Now wrap Twig blocks around any markup we want to be able to override in other templates. Let’s create a wrapper block around all the markup, a content block around the contents of the wrapper, and a label block around the label.

{% block wrapper %}

  <div{{ attributes }}>

    {% block content %}

      {% if label_hidden %}
        {% if multiple %}
          {% for item in items %}
            <div{{ item.attributes }}>{{ item.content }}</div>
          {% endfor %}
        {% else %}
          {% for item in items %}
            {{ item.content }}
          {% endfor %}
        {% endif %}
      {% else %}

        {% block label %}
          <div{{ title_attributes.addClass(title_classes) }}>{{ label }}</div>
        {% endblock label %}

        {% if multiple %}
          <div>
        {% endif %}
        {% for item in items %}
          <div{{ item.attributes }}>{{ item.content }}</div>
        {% endfor %}
        {% if multiple %}
          </div>
        {% endif %}
      {% endif %}

    {% endblock content %}

  </div>

{% endblock wrapper %}

Continuing with the example, we can now create a field--details template with minimal code duplication.

{#
/**
 * @file
 * Theme override to display field items in a "details" element.
 */
#}
{% extends 'field.html.twig' %}

{% block wrapper %}
  <details{{ attributes }}>
    {# Use Twig's block() function to manually print the contents of a given block. This works just like the parent() function, but allows you to call any block you want. #}
    {{ block('content') }}
  </details>
{% endblock %}

{% block label %}
  <summary{{ title_attributes.addClass(title_classes) }}>
    {{ label }}
  </summary>
{% endblock %}

As you can see, we’re only changing the parts that really differ. This means that future changes to the parent template (field.html.twig) will automatically get picked up by this template. When further use-cases arise, and we find a need for overriding other parts of the parent template, we can easily add in new block tags to encapsulate the markup that needs to support variation.

More flexibility with |value and |label filters

Rather than having to implement a separate field template every time you want to change the wrappers around a given field and/or its label, it sure would be nice and tidy to keep all the markup right there in the node template. That’s where two additional custom filters come in: |value, to print just the field items, and |label, to print only the label. Adding to our \Twig_Extension implementation from above.

use Drupal\Core\Render\Element;

class MyTwigExtension extends \Twig_Extension {

  /** {@inheritdoc} */
  public function getName() { ... }

  /**
   * {@inheritdoc}
   */
  public function getFilters() {
    return [
      new \Twig_SimpleFilter('as', [$this, 'suggestThemeHook']),
      new \Twig_SimpleFilter('label', [$this, 'showLabel']),
      new \Twig_SimpleFilter('value', [$this, 'showValue']),
    ];
  }

  /** Adds a theme suggestion to the element. ... */
  public static function suggestThemeHook($element, $suggestion) { ... }

  /**
   * Adds a "__label" theme suggestion to the render array.
   *
   * Note that the pluralize option acts directly on the render array's
   * "#title" key, which may not work for all element types.
   *
   * Why not just return the element's ['#title'] right here? Because it's
   * better to let the Drupal rendering system do its job. E.g., changes to the
   * field label might get made along the way.
   *
   * @param array|null $element
   *   An element render array.
   * @param bool $pluralize
   *   Whether to pluralize the label.
   *
   * @return array
   *   The "label only" render array.
   */
  public static function showLabel($element, $pluralize = FALSE) {

    // Pluralize the label, if requested.
    if ($pluralize && !empty($element['#title']) && count(Element::children($element)) > 1) {
      $element['#title'] .= 's';
    }

    // Add a "label only" theme suggestion to the front of the list.
    return static::suggestThemeHook($element, 'label');
  }

  /**
   * Adds a "__value" theme suggestion to the render array.
   *
   * Note that just removing the "#title" key from the render array causes a
   * PHP notice in a core theme function that expects the key to exist.
   *
   * @param array|null $element
   *   An element render array.
   *
   * @return array
   *   The "value only" render array.
   */
  public static function showValue($element) {
    return static::suggestThemeHook($element, 'value');
  }

}

Now we make templates to be used by each of the new filters. The field--label template is straightforward.

{#
/**
 * @file
 * Theme override to display the field label only.
 */
#}
{{ label }}

The field--value template takes advantage of a template variable and the Twig blocks we added previously.

{#
/**
 * @file
 * Theme override to display field items with no label or wrapper.
 */
#}
{% extends 'field.html.twig' %}

{# Don't print the field label. #}
{% set label_hidden = true %}

{% block wrapper %}
  {# Skip the wrapping div. #}
  {{ block('content') }}
{% endblock %}

And now the <details> element code can live directly in the node--article template. This time, let’s assume the field is required so we can avoid the clumsy empty-checking logic.

<details>
  <summary>{{ content.body|label }}</summary>
  {{ content.body|value }}
</details>

As you can see, we’ve come almost full circle to the original code at the beginning of the post. How to decide which approach to use? Here’s my rule of thumb: If the field is required, prefer the |label and |value filters so that all the markup lives in the node/block/paragraph template. If the field is not required or the same markup is used across multiple fields, prefer the |as() filter.

Ultimately, the implementation you choose will depend on your team’s skills, the project’s requirements, and its content architecture. There are many “right” ways to theme your Drupal, and having the full power of Twig’s tooling up your sleeve will help you create a robust, maintainable, and easily-extended suite of templates for your project. Happy theming!

P.S. – Shout out to @shaal for pointing me to the Drupaltwig Slack instance. Get your invite here and jump in to continue the conversation, ask questions, and share your ideas!

P.P.S. – There's also the DrupalChat channel #twig as a great Slack alternative.

Hawkeye Tenderwolf

Photo of Hawkeye Tenderwolf
Hawkeye Tenderwolf takes great pleasure in writing and reviewing elegant, object-oriented code.