toolshed-8.x-1.x-dev/src/Twig/ToolshedTwigExtension.php
src/Twig/ToolshedTwigExtension.php
<?php
namespace Drupal\toolshed\Twig;
use Drupal\Component\Render\MarkupInterface;
use Drupal\Component\Utility\NestedArray;
use Drupal\Core\Access\AccessResultInterface;
use Drupal\Core\Pager\PagerManagerInterface;
use Drupal\Core\Render\Element\FormElementInterface;
use Drupal\Core\Render\ElementInfoManagerInterface;
use Drupal\Core\Render\RendererInterface;
use Drupal\Core\Template\TwigEnvironment;
use Drupal\views\ViewExecutable;
use Drupal\views\Views;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
use Twig\TwigTest;
/**
* Add empty checks and render functions for Twig.
*
* Adds a cleaner implementation in Twig for:
* + Checking for empty, with checks for Twig debug comments.
* + Is a view empty (has no results).
* + Render if a designated child item is not empty.
*/
class ToolshedTwigExtension extends AbstractExtension {
/**
* The Twig environment service.
*
* @var \Drupal\Core\Template\TwigEnvironment
*/
protected TwigEnvironment $twig;
/**
* The render element plugin manager.
*
* @var \Drupal\Core\Render\ElementInfoManagerInterface
*/
protected ElementInfoManagerInterface $elementManager;
/**
* Drupal active renderer and render context.
*
* @var \Drupal\Core\Render\RendererInterface
*/
protected RendererInterface $renderer;
/**
* Current Twig configurations.
*
* @var array
*/
protected array $twigOpts;
/**
* Create a new instance of the twig extensions for adding Toolshed utilities.
*
* @param \Drupal\Core\Template\TwigEnvironment $twig
* The Twig environment and setttings.
* @param \Drupal\Core\Render\ElementInfoManagerInterface $element_info_manager
* Plugin manager for maintaining element definitions.
* @param \Drupal\Core\Render\RendererInterface $renderer
* Drupal renderer services. Transforms Drupal render arrays and objects
* into HTML.
* @param array $twig_options
* Twig options passed to the twig environment.
*/
public function __construct(TwigEnvironment $twig, ElementInfoManagerInterface $element_info_manager, RendererInterface $renderer, array $twig_options) {
$this->twig = $twig;
$this->elementManager = $element_info_manager;
$this->renderer = $renderer;
$this->twigOpts = $twig_options;
}
/**
* Get the pager manager service.
*
* @return \Drupal\Core\Pager\PagerManagerInterface
* The pager manager service.
*/
protected static function getPagerManager(): PagerManagerInterface {
return \Drupal::service('pager.manager');
}
/**
* {@inheritdoc}
*/
public function getName(): string {
return 'toolshed.twig_utils';
}
/**
* {@inheritdoc}
*/
public function getFunctions(): array {
return [
new TwigFunction('render_if_child', $this->renderIfChild(...), ['is_safe' => ['html']]),
];
}
/**
* {@inheritdoc}
*/
public function getTests(): array {
return [
new TwigTest('element_empty', $this->isElementEmpty(...)),
new TwigTest('view_empty', $this->isViewEmpty(...)),
new TwigTest('block_empty', $this->isContentEmpty(...)),
];
}
/**
* Checks the visible access of an element to see if it will be rendered.
*
* @param array $element
* Element to check visibility access for.
*
* @return bool
* TRUE if the element is seen to have visibility access, and will be
* rendered.
*/
protected function isVisible(array $element): bool {
$access = $element['#access'] ?? NULL;
return !isset($access)
|| $access === TRUE
|| ($access instanceof AccessResultInterface && $access->isAllowed());
}
/**
* Check if nested render arrays are all empty.
*
* @param array $children
* An array of children renderables. This can actually be the element itself
* since all the attributes of the pass array are checked.
*
* @return bool
* TRUE if the children elements are empty, otherwise returns FALSE if any
* children are known to have renderable content.
*/
protected function areChildrenEmpty(array $children = []): bool {
// Iterate through the render array children are either invisible
// due to element access of empty.
foreach ($children as $key => $child) {
if ((!is_string($key) || $key[0] !== '#') && is_array($child)) {
if (!$this->isElementEmpty($child)) {
return FALSE;
}
}
}
return TRUE;
}
/**
* Determine if an element is empty in terms of rendered content.
*
* @param array|null $element
* Element to examine to check for empty value. NULL is possible coming
* from a Twig template, allowing for this makes it easier to write
* the your template files with a lot of extra checks for NULL.
*
* @return bool
* If element can be considered empty or not. TRUE implies that
* the content is considered empty.
*/
public function isElementEmpty(?array $element): bool {
// Remove common render properties that do not contribute to the content
// having renderable content. This is similar to
// \Drupal\Core\Render\Element::isEmpty() checking excluding #cache.
unset($element['#cache'], $element['#attributes']);
if (empty($element) || !$this->isVisible($element)) {
return TRUE;
}
// It is not possible to know if a placeholder or lazy builder will result
// in visible content at this point, because rendering is being deferred.
if (!empty($element['#lazy_builder']) || !empty($element['#placeholder'])) {
return FALSE;
}
$type = $element['#type'] ?? @$element['#theme'];
// If the type / theme is empty, this array is just a container for nested
// render arrays. Check for markup or empty children.
//
// This also captures render elements that use "theme_wrappers".
if (!$type) {
return $this->isContentEmpty($element);
}
switch ($type) {
case 'fieldset':
case 'details':
if (!empty($element['#title']) || !empty($element['#description'])) {
return FALSE;
}
case 'container':
case 'form_element':
// These are containers for other elements, they are empty if their
// children are all empty.
return $this->areChildrenEmpty($element);
case 'field':
// \Drupal\Core\Field\FieldListInterface::isEmpty() will indicate if
// a field is empty or not. Typically fields generate field render
// arrays if the field was empty to start with, but we'll still test for
// instances where that behavior are overridden.
return empty($element['#items']) || $element['#items']->isEmpty();
case 'value':
// Form element which does not need to render any output.
return TRUE;
case 'table':
return empty($element['#rows']) && $this->areChildrenEmpty($element);
case 'form':
case 'link':
case 'file':
case 'more_link':
case 'iconset_icon':
// These render elements that we'll consider as always rendering.
return FALSE;
case 'links':
case 'dropbutton':
case 'operstaions':
return empty($element['#links']) && empty($element['#header']);
case 'item_list':
return empty($element['#items']);
case 'view':
return $this->isViewEmpty($element);
case 'block':
return $this->isContentEmpty($element);
case 'pager':
if (isset($element['#element'])) {
/** @var \Drupal\Core\Pager\PagerManagerInterface $pagerManager */
$pagerManager = static::getPagerManager();
if ($pager = $pagerManager->getPager()) {
return (bool) !$pager->getTotalItems();
}
}
return TRUE;
case 'processed_text':
// @todo Also check if text only consists of white-space tags
// such as "<p>", "<br>", and " ".
return $this->isMarkupEmpty($element['#text']);
default:
$elementDef = isset($element['#type']) ? $this->elementManager->getDefinition($type, FALSE) : NULL;
// Consider form elements to always have content (with the exception of
// "value" but already handled in the switch statement).
if ($elementDef && is_a($elementDef['class'], FormElementInterface::class, TRUE)) {
return FALSE;
}
// Is this just a markup element?
foreach (['#markup', '#plain_text'] as $idx) {
if (isset($element[$idx])) {
return $this->isMarkupEmpty($element[$idx]);
}
}
// Unfortunately we can't tell what this theme or type is, and therfore
// cannot tell if it is actually empty or not. We can only take a
// shortcut and exit if it has non-empty children.
if (!$this->areChildrenEmpty($element)) {
return FALSE;
}
// If we are not able to determine this information easily, we fallback
// to the hammer approach of rendering, and checking for empty.
//
// @todo Chip away at this approach for better tests for empty content.
$str = $this->renderer->render($element);
// If the Twig debug flag is enabled, there are comments that need to be
// removed from the output before checking for empty.
if (!empty($this->twigOpts['debug'])) {
// @todo strip comments.
}
return empty(trim($str));
}
}
/**
* Determine if a view should be considered empty (has no results).
*
* @param mixed $view
* Either a ViewExecutable object, or an array or string that can be used
* to determine and load the correct views object.
*
* @return bool
* TRUE if the view query is returning empty results. This filter ignores
* if the view is configure to return an empty message or still expected
* to render even if empty.
*
* @todo Add check for empty plugin behavior configured for view display.
* A view that is configured to display an empty message might not be
* considered empty, and is intended to still display in most cases.
*/
public function isViewEmpty($view): bool {
if (empty($view)) {
return TRUE;
}
if (!$view instanceof ViewExecutable) {
if (is_string($view)) {
[$viewId, $displayId] = explode(':', $view);
$args = func_get_args();
array_shift($args);
}
elseif (is_array($view)) {
if (isset($view['#type']) && $view['#type'] === 'view') {
$viewId = $view['#name'];
$displayId = $view['#display_id'];
$args = !empty($view['#arguments']) ? $view['#arguments'] : [];
}
else {
[$viewId, $displayId] = $view;
$args = func_get_args();
array_shift($args);
}
}
// If either of these are missing, skip and consider the view empty.
if (empty($viewId) || empty($displayId)) {
return TRUE;
}
// Try to load and set the view information.
$view = Views::getView($viewId);
if (!$view || !$view->setDisplay($displayId)) {
return TRUE;
}
if (!empty($args)) {
$view->setArguments($args);
}
}
// Execute the view and check if there are any results. No results
// means that the view is empty.
return !$view->execute() || empty($view->result);
}
/**
* Check if a we're able to determine if a block should be considered empty.
*
* @param array $element
* Renderable array representing the block content.
*
* @return bool
* TRUE if the block content is empty. FALSE if otherwise.
*/
public function isContentEmpty(array $element): bool {
// First check for markup, this takes precedence over child render items.
foreach (['#markup', '#plain_text'] as $idx) {
if (isset($element[$idx])) {
return $this->isMarkupEmpty($element[$idx]);
}
}
// If not just a simple text block.
return $this->areChildrenEmpty($element);
}
/**
* Render if child is considered not empty.
*
* @param array $element
* Render array to render if the child is not empty.
* @param array $parents
* Array of parent keys to the child relative to $element.
*
* @return \Drupal\Component\Render\MarkupInterface|null
* Render array to render. Will be the value of element if the child is
* not empty, but an empty array (render nothing) if child is determined
* to be empty. Empty is determined by static::isElementEmpty().
*/
public function renderIfChild(array $element, array $parents): ?MarkupInterface {
$child = &NestedArray::getValue($element, $parents);
if ($child && !$this->isElementEmpty($child)) {
return $this->renderer->render($element);
}
return NULL;
}
/**
* Detect if a string or MarkupInterface object is considered empty.
*
* @param \Drupal\Component\Render\MarkupInterface|string $text
* Either a renderable markup object or a string of markup to output.
*
* @return bool
* TRUE if the passed markup should be considered empty or, FALSE if the
* markup contains anything.
*/
public function isMarkupEmpty(MarkupInterface|string $text): bool {
if ($text instanceof MarkupInterface) {
return !($text instanceof \Countable ? $text->count() : strlen((string) $text));
}
return empty(trim($text));
}
}
