Improving Twig Include for Sub Themes Via Custom Extension (Drupal 8)

June 25, 2018

Twig is a templating engine used by Drupal 8 websites that makes inserting dynamic content into web pages easier.

And while Twig has some useful functionality built-in, such as an include function which allows separate files to reference each other and enables code to be more modular, I want to share how and why we improve upon this default include function by way of a custom Twig extension.

Why Mess With It?

You may be wondering “why would someone want to change the core Twig include function?” There is nothing broken with the core include function, but it could be more flexible, especially when dealing with sub themes.

The need for a more flexible include function arose when I was attempting to make a sub theme of a preexisting custom theme. A template file within the base theme used the Twig include function to reference another file. Simply copying the file into the sub theme caused an error because the path defined in the include would only ever look in one place. If the file only existed in one of the themes, and the path was checking the wrong theme, it would fail to find the file, throwing a fatal error.

What I needed was an include that checked multiple locations for the file. Twig includes are commonly implemented as either a hard-coded path or involve some variable combined with a string to dynamically generate the path, both methods can cause issues when creating a sub theme.

{# Hard-coded theme path #}
{{ include(‘themes/custom/theme/path/file.html.twig’) }}

{# Variable theme path #}
{{ include(directory ~ ‘/path/file.html.twig’) }}

Hard-coded path includes reference one specific theme location, which when copied between themes, will always reference the exact location of the original set path. Variable paths allow for a dynamic reference to the current theme, allowing code to be copied between themes without the need to update paths. Variable paths are a step in the right direction but do not fallback to the parent theme if the file is not found.

It’s worth noting that it’s possible to pass multiple paths into a Twig include like so:

{# Theme path array #}
{{ include([‘themes/custom/theme-1/path/file.html.twig’, ‘themes/custom/theme-2/path/file.html.twig’]) }}

The first path to return a file will be the one used. This is the type of fallback functionality we want, but it’s not very scalable. Whenever a theme is added or removed, the include array would have to be manually updated.

In a Perfect World

To illustrate the ideal functionality, picture the following theme structures:

  • parent-theme/
    • template.html.twig
    • component.html.twig
  • sub-theme-a/
    • template.html.twig
  • sub-theme-b/
    • component.html.twig

In this example, each sub-theme has the parent theme of “parent-theme” and files named “template.html.twig” include the “component.html.twig” file. These are the files we want each sub-theme to reference automatically without needing to update the path declared in the include:

  • -sub-theme-a:
    • sub-theme-a/template.html.twig (current default sub-theme override)
    • parent-theme/component.html.twig (custom include fallback)
  • sub-theme-b:
    • parent-theme/template.html.twig (current default sub-theme fallback)
    • sub-theme-b/component.html.twig (custom include override)

To achieve this, our ideal syntax could look something like this:

{# Include relative to the theme, overridden by sub themes. #}
{{ include(‘path/file.html.twig’) }}

The key here is to use a path relative to the theme root when defining the file to be included. This way the path is not specific to a theme, allowing for the same path to potentially resolve in any theme.

Building It Ourselves

Fortunately, Twig allows for the creation of custom extensions with which we can add our own custom functionality to Twig. This Twig extension will be in the form of a Drupal 8 module so it can be easily enabled on Drupal 8 projects.

The file structure of the module looks like this:

  • twig_extension_theme_include/
    • src/
      • Theme_Include_Twig_Extension.php
    • twig_extension_theme_include.info.yml
    • twig_extension_theme_include.services.yml

twig_extension_theme_include.info.yml

name: Twig Extension Theme Include
description: Extends Twig core with a custom include function for proper file overrides when working with sub themes.
package: Twig
type: module
core: 8.x

This is just a basic module info file, nothing out of the ordinary.

twig_extension_theme_include.services.yml

services:
  # [module-folder-name].[module-class-name]
  twig_extension_theme_include.Theme_Include_Twig_Extension:
    # Drupal\[module-folder-name].[module-class-name]
    class: Drupal\twig_extension_theme_include\Theme_Include_Twig_Extension
    tags:
      - { name: twig.extension }

The services file is a small piece of code required for Drupal to recognize the custom extension. Within this file we tell Drupal what class it should use and that it’s a Twig extension via the tag: `twig.extension`. In the comments I have broken down the syntax for the few confusing lines.

Theme_Include_Twig_Extension.php

<?php

namespace Drupal\twig_extension_theme_include;

// Create a class that extends Twig_Extension, allowing us to add our own extension.
class Theme_Include_Twig_Extension extends \Twig_Extension {

	public function getName() {
		return 'twig_extension_theme_include.theme_include_twig_extension';
	}

	public function getFunctions() {
		return array(
			// Create a custom Twig function with all the options default Twig include requires.
			new \Twig_SimpleFunction('theme_include', [$this, 'theme_include'], array('needs_environment' =--> true, 'needs_context' => true, 'is_safe' => array('all')))
		);
	}

	// Checks for the existence of a file within a specific theme.
	public function file_exists_in_theme($file_path, $theme) {
		// Get the path to the theme.
		$theme_path = $theme->getPath();
		// Build the path to the file in the theme.
		$theme_file_path = $theme_path . '/' . $file_path;
		// If the file exists in the theme.
		if(file_exists($theme_file_path)) {
			// Return the path to the file in the theme.
			return $theme_file_path;
		}
		// If the file does not exist in the theme.
		else {
			// Return false.
			return false;
		}
	}

	// Returns a call of Twig include with the first file found at a specified path, working up the theme tree.
	public function theme_include(\Twig_Environment $env, $context, $template, $variables = array(), $withContext = true, $ignoreMissing = false, $sandboxed = false) {
		// Get the active theme.
		$active_theme = \Drupal::service('theme.manager')->getActiveTheme();
		
		// If the file exists in the active theme.
		if($active_theme_file_path = $this->file_exists_in_theme($template, $active_theme)) {
			// Call Twig include with the active theme file path.
			return twig_include($env, $context, $active_theme_file_path, $variables, $withContext, $ignoreMissing, $sandboxed);
		}

		// Set a variable to hold the current theme as we loop through. Set initially to the active theme.
		$loop_theme = $active_theme;
		// While the current loop theme has a base theme.
		while($loop_theme->getBaseThemes()) {
			// Set the loop theme to the base theme.
			$loop_theme = array_shift(array_values($loop_theme->getBaseThemes()));
			// If the file exists in the loop theme.
			if($loop_theme_file_path = $this->file_exists_in_theme($template, $loop_theme)) {
				// Call Twig include with the loop theme file path.
				return twig_include($env, $context, $loop_theme_file_path, $variables, $withContext, $ignoreMissing, $sandboxed);
			}
		}

		// Generate a path to the file in the active theme.
		$active_theme_path = $active_theme->getPath() . '/' . $template;
		// No active theme has the file, but we pass the path into the twig include as if the file existed in the active theme directory (let the core twig function handle it).
		return twig_include($env, $context, $active_theme_path, $variables, $withContext, $ignoreMissing, $sandboxed);
	}

}
?>

The code is heavily commented to explain what’s going on line by line, but at a higher level, the `theme_include()` function starts at the active theme and attempts to find the file via the passed path. It uses the `file_exists_in_theme()` function to determine if a theme contains the file or not. If a theme does not have the specified file, the function checks the parent theme, and continues in this pattern until the file is found, or there are no more parent themes.

Once this loop ends, the found file path is sent to the default Twig include function. As a fallback, if no file is found, the path to the file’s expected location within the active theme is sent to the default include function. This is done knowing that if `ignore missing` is not set, a fatal error will occur. The reason we do this is that we want the functionality, including error handling, to remain as close to the original include function as possible. We are merely filtering the data being passed into the include.

It’s important to note that this function does not duplicate the functionality of the standard Twig include, but rather checks for the existence of files before passing a specific path into the Twig include.

This is beneficial for a few reasons: this allows for the reuse of preexisting code, this extension will automatically benefit from updates done to the default include, and the behavior should already be familiar to anyone familiar with the default include. This is also why the function accepts the same arguments as the default include because those arguments are passed on into the default include. To do this properly I referenced the Twig include source code.

Example Usage

With our custom module installed, our normal includes can be switched to theme includes.

{# This #}
{{ include(‘themes/custom/theme/path/file.html.twig’) }}

{# Becomes this #}
{{ theme_include(‘path/file.html.twig’) }}

This allows files utilizing includes to be copied between sub themes while keeping the sub theme override functionality working as intended. You can find a copy of the finished product and usage documentation here: https://github.com/alexspirgel/twig_theme_include.