Region Content Evaluation

June 5, 2023

Determining which Drupal regions have content seems like a reasonably straightforward task, but, as evidenced by this 12+ year old, six-digit node ID issue, it is not. In the course of building a base theme for future Drupal projects I decided it was worth the time to see how well I could work around the problems referenced in that issue in order to get accurate region content flagging working.

The first step is to use template_preprocess_page() to provide flags to the page template as though all the complicating issues didn't exist:

/**
 * Implements template_preprocess_page().
 *
 * Add attributes and provide flags for each region indicating whether or not it
 * has content.
 */
function THEME_preprocess_page(array &$variables) {
  $variables['region_content_flags'] = (isset($variables['page']) && ($region_content_flags = _THEME_region_content_flags($variables['page']))) ? $region_content_flags : [];
}

The actual tests happen in a helper function, because I use the same process in a couple of other places (e.g., in template_preprocess_html() to add some body classes):

/**
 * Implements template_preprocess_html().
 *
 * Add classes to the body tag based on language active regions.
 */
function THEME_preprocess_html(array &$variables) {
  $variables['attributes'] ??= [];
  $variables['attributes']['class'] ??= [];
  if (isset($variables['page']) && ($region_content_flags = _THEME_region_content_flags($variables['page']))) {
    if (isset($region_content_flags['sidebar_count'])) {
      if ($region_content_flags['sidebar_count'] == 0) {
        $variables['attributes']['class'][] = 'no-sidebar';
      }
      else {
        $variables['attributes']['class'][] = 'has-sidebar';
        if ($region_content_flags['sidebar_count'] == 1) {
          $variables['attributes']['class'][] = 'one-sidebar';
        }
        elseif ($region_content_flags['sidebar_count'] == 2) {
          $variables['attributes']['class'][] = 'two-sidebars';
        }
      }
    }
    foreach ($region_content_flags as $region => $region_status) {
      if (($region != 'sidebar_count') && ($region_status)) {
        $variables['attributes']['class'][] = "has-{$region}";
      }
    }
  }
}

Here's the helper function that evaluates regions for content:

/**
 * Determine which regions contain content.
 *
 * This theme will generally be used as a base theme, so the active theme name
 * must be determined.
 *
 * @param array $page
 *   The $page array.
 */
function _THEME_region_content_flags(array $page) {
  $theme_name = ($active_theme = \Drupal::service('theme.manager')->getActiveTheme()) ? $active_theme->getName() : 'THEME';
  $region_content_flags = [
    'sidebar_count' => 0,
  ];
  if ($region_data = system_region_list($theme_name)) {
    $regions = array_keys($region_data);
    foreach ($regions as $region) {
      $region_content_flags[$region] = FALSE;
    }
    $content_tags = [
      '<drupal-render-placeholder>',
      '<embed>',
      '<hr>',
      '<iframe>',
      '<img>',
      '<input>',
      '<link>',
      '<object>',
      '<script>',
      '<source>',
      '<style>',
      '<video>',
    ];
    $content_tags_string = implode('', $content_tags);
    foreach ($regions as $region) {
      $region_content = \Drupal::service('renderer')->render($page[$region]);
      if (!empty(trim(strip_tags($region_content, $content_tags_string)))) {
        $region_content_flags[$region] = TRUE;
        if (strpos($region, 'sidebar_') === 0) {
          $region_content_flags['sidebar_count']++;
        }
      }
    }
  }
  return $region_content_flags;
}

Note the list of tags excluded from strip_tags()—this should ensure that if a region contains only, say, an img tag, it's correctly evaluated as not empty. Note also the sidebar count, which is useful for CSS layout rules (at least until the :has() selector is sufficiently supported).

page.html.twig now has access to a variable region_content_flags that usually accurately reflects each region's status. Using the header region as an example:

{% if region_content_flags.header %}
  {% set header = page.header|render %}
  <header class="header region-container">
    {{ header }}
  </header>
{% endif %}

If it were not for all the issues raised in that 12-year old issue, this would be enough. A simple example of why it isn't: <drupal-render-placeholder> causes a region's content flag to be set to TRUE, however it's entirely possible that when Drupal gets around to replacing that placeholder the replacement is actually empty (based on the current path, or the current user, or any number of other things).

I don't like relying on JavaScript for functionality this fundamental, but for now there's no choice:

(function ($, Drupal, once) {
  Drupal.behaviors.THEMERegions = {
    attach: function(context, settings){
      once('THEME_regions', 'body', context).forEach(function(element){
        $('.region').each(function(){
          var region_name = '';
          var class_list = $(this).attr('class');
          var classes = class_list.split(/\s+/);
          $.each(classes, function(index, value){
            if (value.substr(0, 11) == 'region--id-') region_name = value.substr(11);
          });
          if ((region_name != '') && ($.trim($(this).children('.region-content').html()) == '')) {
            // Regions may be nested, so don't remove the container unless it's
            // also empty.
            if ($.trim($(this).closest('.region-container').html()) == '') $(this).closest('.region-container').remove();
            else $(this).remove();
            $('body').removeClass('has-' + region_name);
            if (region_name.includes('sidebar')) {
              if ($('body').hasClass('two-sidebars')) {
                $('body').removeClass('two-sidebars').addClass('one-sidebar');
              }
              else if ($('body').hasClass('one-sidebar')) {
                $('body').removeClass('one-sidebar').removeClass('has-sidebar').addClass('no-sidebar');
              }
            }
          }
        });
      });
    }
  }
})(jQuery, Drupal, once);

A couple of things to notice here:

  1. The region classes have to be predictable for if (value.substr(0, 11) == 'region--id-')… to work—another thing that this base theme does is to try to provide consistent, standardized classes for each element. That will be another post when I get around to it.
  2. Note the need to adjust the body sidebars class manually (e.g., from two-sidebars to one-sidebar) if an empty sidebar exists.
Tags
The Tablelands in Gros Morne National Park

The Tablelands in Gros Morne National Park, August 2017