mirror of
https://gitlab.unicamp.br/infimecc_drupal11_modules/structural_pages.git
synced 2026-03-08 01:27:42 -03:00
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:
63
css/site-structure-menu.css
Normal file
63
css/site-structure-menu.css
Normal 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;
|
||||
}
|
||||
5
site_structure.libraries.yml
Normal file
5
site_structure.libraries.yml
Normal file
@@ -0,0 +1,5 @@
|
||||
menu:
|
||||
version: VERSION
|
||||
css:
|
||||
component:
|
||||
css/site-structure-menu.css: {}
|
||||
@@ -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().
|
||||
*/
|
||||
|
||||
451
src/Plugin/Block/SiteStructureMenuBlock.php
Normal file
451
src/Plugin/Block/SiteStructureMenuBlock.php
Normal 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']);
|
||||
}
|
||||
|
||||
}
|
||||
63
templates/site-structure-menu.html.twig
Normal file
63
templates/site-structure-menu.html.twig
Normal 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 %}
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user