component_library-1.0.x-dev/src/OverrideMode.php
src/OverrideMode.php
<?php
declare(strict_types=1);
namespace Drupal\component_library;
use Drupal\Component\Serialization\Json;
use Drupal\Component\Uuid\UuidInterface;
use Drupal\component_library\Entity\ComponentOverride;
use Drupal\component_library\Event\AddVariablesPlaceholderEvent;
use Drupal\component_library\Event\OverrideIgnoreTemplateEvent;
use Drupal\component_library\Event\ReplaceVariablesPlaceholderEvent;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Config\ImmutableConfig;
use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Logger\LoggerChannelFactoryInterface;
use Drupal\Core\Render\RendererInterface;
use Drupal\Core\Routing\RedirectDestinationInterface;
use Drupal\Core\Session\AccountProxyInterface;
use Drupal\Core\Theme\Registry;
use Drupal\Core\Theme\ThemeManagerInterface;
use Drupal\Core\Url;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Twig\Loader\LoaderInterface;
final class OverrideMode {
private Registry $registry;
private UuidInterface $uuid;
private ImmutableConfig $config;
private LoaderInterface $twigLoader;
private RendererInterface $renderer;
private AccountProxyInterface $currentUser;
private ThemeManagerInterface $themeManager;
private EventDispatcherInterface $dispatcher;
private LoggerChannelFactoryInterface $logger;
private EntityStorageInterface $overrideStorage;
private RedirectDestinationInterface $destination;
public function __construct(ThemeManagerInterface $theme_manager, Registry $registry, RedirectDestinationInterface $destination, LoggerChannelFactoryInterface $logger, RendererInterface $renderer, EventDispatcherInterface $dispatcher, UuidInterface $uuid, LoaderInterface $twig_loader, ConfigFactoryInterface $config, AccountProxyInterface $current_user, EntityTypeManagerInterface $entity_type_manager) {
$this->uuid = $uuid;
$this->logger = $logger;
$this->registry = $registry;
$this->renderer = $renderer;
$this->dispatcher = $dispatcher;
$this->twigLoader = $twig_loader;
$this->destination = $destination;
$this->currentUser = $current_user;
$this->themeManager = $theme_manager;
$this->config = $config->get('component_library.override_mode.settings');
$this->overrideStorage = $entity_type_manager->getStorage('component_override');
}
/**
* Attach override libraries.
*
* @param string $template_file
* The template to override.
* @param array $variables
* The template variables.
*
* @throws \Exception
*/
public function attachLibraries(string $template_file, array $variables): void {
$override = $this->twigLoader->getOverrideByName($template_file);
if ($override instanceof ComponentOverride) {
$libraries = $override->get('libraries');
if ($libraries) {
$bubbleable = [];
foreach ($libraries as $library) {
$bubbleable['#attached']['library'][] = $library;
}
$this->renderer->render($bubbleable);
}
}
}
/**
* Prepare override details.
*
* Sets up the URL and settings that override_mode.js needs to prepopulate
* the ComponentOverrideForm.
*
* @param string $template_file
* The template to override.
* @param array $variables
* The template variables.
*
* @return string|null
* The uuid used as a front-end selector or NULL.
*
* @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
* @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
*/
public function prepareOverrideDetails(string $template_file, array $variables): ?string {
if (!$this->config->get('override_mode')) {
return NULL;
}
if (!$this->currentUser->hasPermission('access override mode')) {
return NULL;
}
$ignore = $this->dispatcher->dispatch(new OverrideIgnoreTemplateEvent($template_file, $variables))->isIgnored();
if ($ignore) {
return NULL;
}
$size = $this->examineArray($variables);
if ($size > $this->config->get('variables_max_size')) {
$this->logger->get('component_library')->warning('Size (%size) exceeds variables max size for (%template).', [
'%size' => $size,
'%template' => $template_file,
]);
return NULL;
}
$memory_usage = \round(\memory_get_usage() / 1_048_576);
if ($memory_usage > $this->config->get('memory_limit')) {
$this->logger->get('component_library')->warning('Usage (%mem_usage) exceeds max memory limit for %template.', [
'%mem_usage' => $memory_usage,
'%template' => $template_file,
]);
return NULL;
}
$uuid = $this->getUuid();
$base_hook = $variables['theme_hook_original'];
$suggestions = $variables['theme_hook_suggestions'];
$cache_tag = "override:$base_hook";
\array_unshift($suggestions, $base_hook);
$theme = $this->themeManager->getActiveTheme()->getName();
// Get the current theme registry.
$theme_suggestion = '';
$theme_registry = $this->registry->getRuntime();
foreach (\array_reverse($suggestions) as $theme_suggestion) {
if ($theme_registry->has($theme_suggestion)) {
break;
}
}
if ($theme_suggestion === '') {
return NULL;
}
$overrides = $this->overrideStorage->loadByProperties([
'override' => $theme_suggestion,
'theme' => $theme,
]);
// Create a URL to add or update an existing override.
$options = [
'query' => [
'uuid' => $uuid,
'override_options' => $suggestions,
'theme_suggestion' => $theme_suggestion,
'destination' => $this->destination->get(),
],
];
if (empty($overrides)) {
$route = 'entity.component_override.add_form';
$route_parameters = [
'theme' => $theme,
];
$options['query'] += [
'base_hook' => $base_hook,
'cache' => $cache_tag,
'plugin_value' => 'override_mode',
];
}
else {
$route = 'entity.component_override.edit_form';
$route_parameters = [
'component_override' => \array_key_first($overrides),
];
}
$bubbleable = [];
$bubbleable['#cache']['tags'][] = $cache_tag;
// Pass details to override this template to JS.
$bubbleable['#attached']['drupalSettings']['overrides'][$uuid] = [
'uuid' => $uuid,
'url' => Url::fromRoute($route, $route_parameters, $options)->toString(),
'theme_suggestion' => $theme_suggestion,
];
// This needs to be refactored as it now throws
// Deprecated function: Creation of dynamic property Drupal\Core\Template\Attribute::$#override_mode_breadcrumb is deprecated in Drupal\component_library\OverrideMode->traverse()
// $variables = $this->addPlaceholders($variables);
// try {
// $bubbleable['#attached']['html_head'][] = [
// [
// '#type' => 'html_tag',
// '#tag' => 'script',
// '#weight' => -1,
// '#attributes' => [
// 'type' => 'application/json',
// 'data-uuid' => $uuid,
// ],
// '#value' => Json::encode([]),
// ],
// $uuid,
// ];
// }
// catch (\Throwable $t) {
// $this->logger->get('component_library')->notice("component_library Override Mode: failed to serialize $theme_suggestion variables.\n" . $t->getMessage());
// }
$this->renderer->render($bubbleable);
return $uuid;
}
/**
* Add placeholders.
*
* Variables are used when previewing template changes. They are serialized,
* passed to JS, and then submitted to the ComponentOverrideForm form in an
* AJAX POST for previews in Override Mode before being saved to an Component
* Override for previewing again at a later date. Let's drastically cut down
* on the size of the variables by adding placeholders for verbose items like
* entities, views, etc.
*
* @param array $variables
* The template variables.
*
* @return array
* The variables with placeholders.
*/
private function addPlaceholders(array $variables): array {
return $this->traverse($variables, [$this, 'askForPlaceholder']);
}
/**
* Replace placeholders.
*
* @param array $variables
* The template variables.
*
* @return array
* The variables with replacements.
*/
public function replacePlaceholders(array $variables): array {
return $this->traverse($variables, [$this, 'askForReplacement']);
}
/**
* Ask for placeholder.
*
* @param $value
* The item to be replaced.
* @param \Iterator $iterator
* The recursive iterator.
* @param array $variables
* The template variables.
*
* @return mixed|null
* The placeholder or NULL.
*/
private function askForPlaceholder(&$value, \Iterator $iterator, array $variables) {
if (\is_object($value) || (\is_array($value) && isset($value['#type']))) {
$parents = [];
for ($i = 0; $i <= $iterator->getDepth(); $i++) {
$parents[] = $iterator->getSubIterator($i)->key();
}
$event = new AddVariablesPlaceholderEvent($value, $parents, $variables);
$event = $this->dispatcher->dispatch($event);
return $event->getPlaceholder();
}
return NULL;
}
/**
* Ask for replacement.
*
* @param $value
* The item to be replaced.
* @param \Iterator$iterator
* The recursive iterator.
* @param array $variables
* The template variables.
*
* @return mixed|null
* The replacement or NULL.
*/
private function askForReplacement(&$value, \Iterator $iterator, array $variables) {
if (\is_array($value) && isset($value['#override_mode'])) {
$event = new ReplaceVariablesPlaceholderEvent($value['#override_mode']);
$event = $this->dispatcher->dispatch($event);
return $event->getReplacement();
}
return NULL;
}
/**
* Traverse.
*
* Recursively iterate over template variables.
*
* @param array $variables
* The template variables.
* @param callable $callback
* askForPlaceholder or askForReplacement.
*
* @return array
* The modified template variables.
*/
private function traverse(array $variables, callable $callback): array {
$array_iterator = new \RecursiveArrayIterator($variables);
$filter_iterator = new \RecursiveCallbackFilterIterator($array_iterator, [
$this, 'isCyclic',
]);
$recursive_iterator = new \RecursiveIteratorIterator($filter_iterator, \RecursiveIteratorIterator::SELF_FIRST);
$recursive_iterator->setMaxDepth(20);
foreach ($recursive_iterator as $value) {
$replacement = $callback($value, $recursive_iterator, $variables);
if ($replacement) {
$this->replace($recursive_iterator, $replacement);
continue;
}
// Avoid recursion by marking where we've been.
if (\is_object($value)) {
$value->{'#override_mode_breadcrumb'} = TRUE;
}
if (\is_array($value)) {
$value['#override_mode_breadcrumb'] = TRUE;
}
}
return \iterator_to_array($recursive_iterator);
}
/**
* Replace.
*
* Adds or replaces a placeholder in the template variables being traversed.
*
* @param \RecursiveIteratorIterator $iterator
* The iterator.
* @param mixed $replacement
* The item or placeholder to be replaced.
*/
private function replace(\RecursiveIteratorIterator $iterator, $replacement): void {
$current_depth = $iterator->getDepth();
for ($sub_depth = $current_depth; $sub_depth >= 0; $sub_depth--) {
$subIterator = $iterator->getSubIterator($sub_depth);
$subIterator->offsetSet($subIterator->key(), ($sub_depth === $current_depth ? $replacement : $iterator->getSubIterator($sub_depth + 1)->getArrayCopy()));
}
}
/**
* Is cyclic.
*
* RecursiveCallbackFilterIterator filter callback to prevent recursion.
*
* @param mixed $current
* The current item being traversed.
* @param string $key
* The items key.
* @param \RecursiveArrayIterator $iterator
* The iterator.
*
* @return bool
* Whether or not to traverse this item.
*/
public function isCyclic($current, string $key, \RecursiveArrayIterator $iterator): bool {
// Skip closures.
if ($current instanceof \Closure) {
return FALSE;
}
// Avoid infinite loops by checking if we've been here before.
// e.g. View > query > view > query ...
if (\is_array($current) && isset($current['#override_mode_breadcrumb'])) {
return FALSE;
}
if (\is_object($current) && isset($current->{'#override_mode_breadcrumb'})) {
return FALSE;
}
// Some arrays are impossible to detect reference recursion by marking where
// you have already been.
//
// e.g. #groups when set as $groups[$group][] = &$element
// in RenderElement::processGroup
//
// Let's leverage count($current, COUNT_RECURSIVE) to detect infinite
// reference recursion.
//
// @see https://hbgl.dev/detecting-cyclic-arrays-in-php-a-new-approach/
static $parents;
if (\is_null($parents)) {
$parents = [];
}
if (\is_array($current)) {
$is_recursive = $this->examineArray($current, TRUE);
if ($is_recursive) {
// This array is recursive, but lets continue adding and replacing
// placeholders until it starts repeating.
$parents[] = $key;
$recursion_depth = 5;
if (\count($parents) >= 5) {
$slice = \array_slice($parents, -$recursion_depth, $recursion_depth);
foreach ($parents as $parents_key => $parent) {
if ($parent === $slice[0]) {
$repeating = TRUE;
foreach ($slice as $value) {
if ($value === $parents[$parents_key]) {
$parents_key++;
continue;
}
$repeating = FALSE;
}
if ($repeating) {
return FALSE;
}
}
}
}
}
}
return TRUE;
}
/**
* Examine array.
*
* @param array $variables
* The array to examine.
* @param bool $detect_recursion
* Whether or not to return the array size or if the array has infinite
* recursion.
*
* @return bool|int
* The size of the array or BOOL if detecting_recursion.
*/
private function examineArray(array $variables, bool $detect_recursion = FALSE) {
$is_recursive = FALSE;
\set_error_handler(static function ($errno, $errstr) use (&$is_recursive): void {
$is_recursive = $errno === \E_WARNING && \mb_stripos($errstr, 'recursion');
});
try {
$size = \count($variables, \COUNT_RECURSIVE);
} finally {
\restore_error_handler();
}
if ($detect_recursion) {
return $is_recursive;
}
return $size;
}
/**
* Ensure UUIDs are not duplicated.
*
* @return string
* A unique UUID.
*/
private function getUuid(): string {
$uuids = &drupal_static(__FUNCTION__);
if (!isset($uuids)) {
$uuids = [];
}
do {
$uuid = $this->uuid->generate();
} while (\in_array($uuid, $uuids, TRUE));
$uuids[] = $uuid;
return $uuid;
}
/**
* Get twig debug message.
*
* @return string
* A link to the override.
*/
public function getTwigDebugMessage($name): string {
$debug = '';
$override = $this->twigLoader->getOverrideByName($name);
if ($override) {
$debug = 'Overridden by ';
$debug .= $override->toUrl('edit-form', ['absolute' => TRUE])->toString();
$debug .= ' ';
}
return $debug;
}
}
