Add dynamic menu block based on site structure hierarchy

Implements SiteStructureMenuBlock that renders a navigation menu
from the ancestor entity (term, user, or group) through all
content_page hierarchies. Features configurable depth, active
trail highlighting, and proper cache invalidation.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-03 20:13:51 -03:00
parent 8a42a6f1c1
commit a52f564192
6 changed files with 637 additions and 0 deletions

View File

@@ -0,0 +1,451 @@
<?php
declare(strict_types=1);
namespace Drupal\site_structure\Plugin\Block;
use Drupal\Core\Block\BlockBase;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\group\Entity\GroupInterface;
use Drupal\node\NodeInterface;
use Drupal\taxonomy\TermInterface;
use Drupal\user\UserInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Provides a dynamic menu block based on site structure hierarchy.
*
* @Block(
* id = "site_structure_menu",
* admin_label = @Translation("Site Structure Menu"),
* category = @Translation("Site Structure"),
* context_definitions = {
* "node" = @ContextDefinition("entity:node", label = @Translation("Node"), required = FALSE),
* }
* )
*/
class SiteStructureMenuBlock extends BlockBase implements ContainerFactoryPluginInterface {
/**
* Maximum depth to prevent infinite loops.
*/
protected const MAX_DEPTH = 10;
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected EntityTypeManagerInterface $entityTypeManager;
/**
* The current route match.
*
* @var \Drupal\Core\Routing\RouteMatchInterface
*/
protected RouteMatchInterface $routeMatch;
/**
* Cache tags collected during tree building.
*
* @var array
*/
protected array $cacheTags = [];
/**
* {@inheritdoc}
*/
public function __construct(
array $configuration,
$plugin_id,
$plugin_definition,
EntityTypeManagerInterface $entity_type_manager,
RouteMatchInterface $route_match,
) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->entityTypeManager = $entity_type_manager;
$this->routeMatch = $route_match;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static {
return new static(
$configuration,
$plugin_id,
$plugin_definition,
$container->get('entity_type.manager'),
$container->get('current_route_match'),
);
}
/**
* {@inheritdoc}
*/
public function defaultConfiguration(): array {
return [
'max_depth' => 3,
'show_ancestor_title' => TRUE,
'expand_active_trail' => TRUE,
] + parent::defaultConfiguration();
}
/**
* {@inheritdoc}
*/
public function blockForm($form, FormStateInterface $form_state): array {
$form = parent::blockForm($form, $form_state);
$form['max_depth'] = [
'#type' => 'number',
'#title' => $this->t('Maximum depth'),
'#description' => $this->t('Maximum number of levels to display.'),
'#default_value' => $this->configuration['max_depth'],
'#min' => 1,
'#max' => self::MAX_DEPTH,
];
$form['show_ancestor_title'] = [
'#type' => 'checkbox',
'#title' => $this->t('Show ancestor title'),
'#description' => $this->t('Display the ancestor entity title as menu header.'),
'#default_value' => $this->configuration['show_ancestor_title'],
];
$form['expand_active_trail'] = [
'#type' => 'checkbox',
'#title' => $this->t('Expand active trail'),
'#description' => $this->t('Automatically expand menu items in the active trail.'),
'#default_value' => $this->configuration['expand_active_trail'],
];
return $form;
}
/**
* {@inheritdoc}
*/
public function blockSubmit($form, FormStateInterface $form_state): void {
$this->configuration['max_depth'] = $form_state->getValue('max_depth');
$this->configuration['show_ancestor_title'] = $form_state->getValue('show_ancestor_title');
$this->configuration['expand_active_trail'] = $form_state->getValue('expand_active_trail');
}
/**
* {@inheritdoc}
*/
public function build(): array {
$this->cacheTags = [];
// Get current context.
$current_node = $this->getCurrentNode();
$ancestor = $this->findAncestor($current_node);
if (!$ancestor) {
return [];
}
$this->cacheTags[] = $ancestor->getEntityTypeId() . ':' . $ancestor->id();
// Build menu tree.
$tree = $this->buildTree($ancestor);
if (empty($tree)) {
return [];
}
// Determine active trail.
$active_trail = [];
if ($current_node && $this->configuration['expand_active_trail']) {
$active_trail = $this->getActiveTrail($current_node);
}
$build = [
'#theme' => 'site_structure_menu',
'#ancestor' => $ancestor,
'#tree' => $tree,
'#active_trail' => $active_trail,
'#show_ancestor_title' => $this->configuration['show_ancestor_title'],
'#cache' => [
'tags' => $this->cacheTags,
'contexts' => ['route', 'url.path'],
],
];
return $build;
}
/**
* Gets the current node from context or route.
*
* @return \Drupal\node\NodeInterface|null
* The current node or NULL.
*/
protected function getCurrentNode(): ?NodeInterface {
// Try to get from block context first.
try {
$node = $this->getContextValue('node');
if ($node instanceof NodeInterface) {
return $node;
}
}
catch (\Exception $e) {
// Context not available.
}
// Fall back to route.
$node = $this->routeMatch->getParameter('node');
return $node instanceof NodeInterface ? $node : NULL;
}
/**
* Finds the ancestor entity for the given node.
*
* @param \Drupal\node\NodeInterface|null $node
* The node to find ancestor for.
*
* @return \Drupal\Core\Entity\EntityInterface|null
* The ancestor entity (term, user, or group) or NULL.
*/
protected function findAncestor(?NodeInterface $node): ?EntityInterface {
if (!$node) {
// Check if we're on a taxonomy term, user, or group page.
return $this->getAncestorFromRoute();
}
if (!$node->hasField('field_parent_page')) {
return NULL;
}
$visited = [];
$current = $node;
$max_iterations = self::MAX_DEPTH;
while ($current && $max_iterations > 0) {
$max_iterations--;
if (isset($visited[$current->id()])) {
break;
}
$visited[$current->id()] = TRUE;
if (!$current->hasField('field_parent_page') || $current->get('field_parent_page')->isEmpty()) {
// Node without parent - check if it has a direct section.
if ($current->hasField('field_site_section') && !$current->get('field_site_section')->isEmpty()) {
return $current->get('field_site_section')->entity;
}
return NULL;
}
$parent_field = $current->get('field_parent_page')->first();
$parent_type = $parent_field->target_type ?? NULL;
$parent_id = $parent_field->target_id ?? NULL;
if (!$parent_type || !$parent_id) {
break;
}
// If parent is not a node, it's our ancestor.
if ($parent_type !== 'node') {
$storage = $this->entityTypeManager->getStorage($parent_type);
return $storage->load($parent_id);
}
// Parent is a node, continue traversing.
$current = $this->entityTypeManager->getStorage('node')->load($parent_id);
}
return NULL;
}
/**
* Gets ancestor entity from current route (term, user, or group page).
*
* @return \Drupal\Core\Entity\EntityInterface|null
* The ancestor entity or NULL.
*/
protected function getAncestorFromRoute(): ?EntityInterface {
// Check taxonomy term.
$term = $this->routeMatch->getParameter('taxonomy_term');
if ($term instanceof TermInterface && $term->bundle() === 'site_section') {
return $term;
}
// Check user.
$user = $this->routeMatch->getParameter('user');
if ($user instanceof UserInterface) {
return $user;
}
// Check group (if module exists).
$group = $this->routeMatch->getParameter('group');
if ($group instanceof GroupInterface) {
return $group;
}
return NULL;
}
/**
* Builds the menu tree for an ancestor.
*
* @param \Drupal\Core\Entity\EntityInterface $ancestor
* The ancestor entity.
*
* @return array
* The menu tree structure.
*/
protected function buildTree(EntityInterface $ancestor): array {
$root_pages = $this->getChildPages($ancestor->getEntityTypeId(), (string) $ancestor->id());
if (empty($root_pages)) {
return [];
}
$tree = [];
$max_depth = (int) $this->configuration['max_depth'];
foreach ($root_pages as $page) {
$tree[] = $this->buildBranch($page, 1, $max_depth);
}
return $tree;
}
/**
* Builds a branch of the menu tree recursively.
*
* @param \Drupal\node\NodeInterface $node
* The node for this branch.
* @param int $current_depth
* The current depth level.
* @param int $max_depth
* The maximum depth to traverse.
*
* @return array
* The branch structure.
*/
protected function buildBranch(NodeInterface $node, int $current_depth, int $max_depth): array {
$this->cacheTags[] = 'node:' . $node->id();
$branch = [
'entity' => $node,
'id' => $node->id(),
'title' => $node->label(),
'url' => $node->toUrl()->toString(),
'depth' => $current_depth,
'children' => [],
];
if ($current_depth < $max_depth) {
$children = $this->getChildPages('node', (string) $node->id());
foreach ($children as $child) {
$branch['children'][] = $this->buildBranch($child, $current_depth + 1, $max_depth);
}
}
return $branch;
}
/**
* Gets child content pages for a given parent entity.
*
* @param string $parent_type
* The parent entity type.
* @param string $parent_id
* The parent entity ID.
*
* @return \Drupal\node\NodeInterface[]
* Array of child nodes.
*/
protected function getChildPages(string $parent_type, string $parent_id): array {
$query = $this->entityTypeManager->getStorage('node')->getQuery()
->accessCheck(TRUE)
->condition('type', 'content_page')
->condition('status', 1)
->condition('field_parent_page.target_type', $parent_type)
->condition('field_parent_page.target_id', $parent_id)
->sort('title', 'ASC');
$nids = $query->execute();
if (empty($nids)) {
return [];
}
$nodes = $this->entityTypeManager->getStorage('node')->loadMultiple($nids);
// Add list cache tag for content pages.
$this->cacheTags[] = 'node_list:content_page';
return array_values($nodes);
}
/**
* Gets the active trail (IDs of nodes from current to root).
*
* @param \Drupal\node\NodeInterface $node
* The current node.
*
* @return array
* Array of node IDs in the active trail.
*/
protected function getActiveTrail(NodeInterface $node): array {
$trail = [$node->id()];
$visited = [$node->id() => TRUE];
$current = $node;
$max_iterations = self::MAX_DEPTH;
while ($max_iterations > 0) {
$max_iterations--;
if (!$current->hasField('field_parent_page') || $current->get('field_parent_page')->isEmpty()) {
break;
}
$parent_field = $current->get('field_parent_page')->first();
$parent_type = $parent_field->target_type ?? NULL;
$parent_id = $parent_field->target_id ?? NULL;
// Stop if parent is not a node (reached ancestor).
if ($parent_type !== 'node' || !$parent_id) {
break;
}
if (isset($visited[$parent_id])) {
break;
}
$visited[$parent_id] = TRUE;
$trail[] = $parent_id;
$current = $this->entityTypeManager->getStorage('node')->load($parent_id);
if (!$current) {
break;
}
}
return $trail;
}
/**
* {@inheritdoc}
*/
public function getCacheTags(): array {
return Cache::mergeTags(parent::getCacheTags(), ['node_list:content_page']);
}
/**
* {@inheritdoc}
*/
public function getCacheContexts(): array {
return Cache::mergeContexts(parent::getCacheContexts(), ['route', 'url.path']);
}
}