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,63 @@
/**
* @file
* Styles for the site structure menu block.
*/
.site-structure-menu {
font-size: 0.9rem;
}
.site-structure-menu__title {
font-size: 1.1rem;
margin-bottom: 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid #ddd;
}
.site-structure-menu__title a {
text-decoration: none;
color: inherit;
}
.site-structure-menu__title a:hover {
text-decoration: underline;
}
.site-structure-menu__list {
list-style: none;
margin: 0;
padding: 0;
}
.site-structure-menu__list--level-2,
.site-structure-menu__list--level-3,
.site-structure-menu__list--level-4 {
padding-left: 1rem;
}
.site-structure-menu__item {
margin: 0.25rem 0;
}
.site-structure-menu__link {
display: block;
padding: 0.25rem 0.5rem;
text-decoration: none;
color: #333;
border-radius: 3px;
transition: background-color 0.15s ease;
}
.site-structure-menu__link:hover {
background-color: #f5f5f5;
text-decoration: none;
}
.site-structure-menu__link--active-trail {
font-weight: 600;
color: #0073bd;
}
.site-structure-menu__item--active-trail > .site-structure-menu__link {
background-color: #e8f4fc;
}

View File

@@ -0,0 +1,5 @@
menu:
version: VERSION
css:
component:
css/site-structure-menu.css: {}

View File

@@ -191,6 +191,29 @@ function _site_structure_creates_circular_reference(int|string $node_id, int|str
return FALSE;
}
/**
* Implements hook_theme().
*/
function site_structure_theme(): array {
return [
'site_structure_menu' => [
'variables' => [
'ancestor' => NULL,
'tree' => [],
'active_trail' => [],
'show_ancestor_title' => TRUE,
],
],
'site_structure_menu_tree' => [
'variables' => [
'items' => [],
'active_trail' => [],
'depth' => 0,
],
],
];
}
/**
* Implements hook_token_info().
*/

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']);
}
}

View File

@@ -0,0 +1,63 @@
{#
/**
* @file
* Template for the site structure menu block.
*
* Available variables:
* - ancestor: The ancestor entity (term, user, or group).
* - tree: Array of menu tree items, each containing:
* - id: Node ID.
* - title: Node title.
* - url: Node URL.
* - depth: Current depth level.
* - children: Array of child items.
* - entity: The node entity.
* - active_trail: Array of node IDs in the active trail.
* - show_ancestor_title: Whether to show the ancestor title as header.
*/
#}
{{ attach_library('site_structure/menu') }}
{% if tree is not empty %}
<nav class="site-structure-menu" aria-label="{{ 'Site structure navigation'|t }}">
{% if show_ancestor_title and ancestor %}
<h2 class="site-structure-menu__title">
{% if ancestor.toUrl is defined %}
<a href="{{ ancestor.toUrl }}">{{ ancestor.label }}</a>
{% else %}
{{ ancestor.label }}
{% endif %}
</h2>
{% endif %}
<ul class="site-structure-menu__list site-structure-menu__list--level-1">
{% for item in tree %}
{{ _self.menu_item(item, active_trail, 1) }}
{% endfor %}
</ul>
</nav>
{% endif %}
{% macro menu_item(item, active_trail, depth) %}
{% set is_active = item.id in active_trail %}
{% set has_children = item.children is not empty %}
{% set classes = [
'site-structure-menu__item',
'site-structure-menu__item--level-' ~ depth,
is_active ? 'site-structure-menu__item--active-trail',
has_children ? 'site-structure-menu__item--has-children',
] %}
<li{{ create_attribute().addClass(classes) }}>
<a href="{{ item.url }}" class="site-structure-menu__link{% if is_active %} site-structure-menu__link--active-trail{% endif %}">
{{- item.title -}}
</a>
{% if has_children %}
<ul class="site-structure-menu__list site-structure-menu__list--level-{{ depth + 1 }}">
{% for child in item.children %}
{{ _self.menu_item(child, active_trail, depth + 1) }}
{% endfor %}
</ul>
{% endif %}
</li>
{% endmacro %}

View File

@@ -304,3 +304,35 @@ msgstr "Biblioteca"
msgid "IT Services"
msgstr "Informática"
#: src/Plugin/Block/SiteStructureMenuBlock.php
msgid "Site Structure Menu"
msgstr "Menu da Estrutura do Site"
#: src/Plugin/Block/SiteStructureMenuBlock.php
msgid "Maximum depth"
msgstr "Profundidade máxima"
#: src/Plugin/Block/SiteStructureMenuBlock.php
msgid "Maximum number of levels to display."
msgstr "Número máximo de níveis a exibir."
#: src/Plugin/Block/SiteStructureMenuBlock.php
msgid "Show ancestor title"
msgstr "Exibir título do ancestral"
#: src/Plugin/Block/SiteStructureMenuBlock.php
msgid "Display the ancestor entity title as menu header."
msgstr "Exibir o título da entidade ancestral como cabeçalho do menu."
#: src/Plugin/Block/SiteStructureMenuBlock.php
msgid "Expand active trail"
msgstr "Expandir trilha ativa"
#: src/Plugin/Block/SiteStructureMenuBlock.php
msgid "Automatically expand menu items in the active trail."
msgstr "Expandir automaticamente os itens de menu na trilha ativa."
#: templates/site-structure-menu.html.twig
msgid "Site structure navigation"
msgstr "Navegação da estrutura do site"