Files
structural_pages/structural_pages.module
Quintino A. G. Souza 36c3a2e9c0 Altera lógica de Site Section e adiciona redirect em content_page
- Adiciona campo field_redirect_link (link) no bundle content_page;
  EventSubscriber emite redirect 301 quando o campo está preenchido
- field_site_section passa a ser obrigatório
- Formulário de content_page: AJAX no site section, select hierárquico
  de página pai filtrado por seção, validação customizada
- StructuralPagesMenuBlock: MAX_DEPTH 10→50, nova lógica de raiz via
  field_site_section, variável ancestor_url no render array
- Template do menu: novas classes BEM/Gva, suporte a is_redirect,
  usa ancestor_url em vez de chamada Twig direta
- CSS do menu reescrito com estilos flyout/sidebar; Select2 adicionado
  para o campo de página pai no formulário admin
- display_submitted desabilitado no tipo content_page

Co-Authored-By: Bauer <henrique@webcontent.com.br>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 08:04:43 -03:00

618 lines
20 KiB
Plaintext

<?php
/**
* @file
* Primary module hooks for Structural Pages module.
*/
declare(strict_types=1);
use Drupal\Core\Entity\EntityInterface;
use Drupal\node\NodeInterface;
use Drupal\taxonomy\TermInterface;
use Drupal\user\UserInterface;
/**
* Implements hook_entity_presave().
*
* Inherits field_site_section from parent to content_page.
* Validates circular reference in field_parent_page.
*/
function structural_pages_entity_presave(EntityInterface $entity): void {
if (!$entity instanceof NodeInterface || $entity->bundle() !== 'content_page') {
return;
}
// Check if has parent page.
if (!$entity->hasField('field_parent_page') || $entity->get('field_parent_page')->isEmpty()) {
return;
}
$parent_field = $entity->get('field_parent_page')->first();
if (!$parent_field) {
return;
}
$parent_entity_type = $parent_field->target_type ?? NULL;
$parent_id = $parent_field->target_id ?? NULL;
if (!$parent_entity_type || !$parent_id) {
return;
}
// Circular reference validation (only for node parents).
if ($parent_entity_type === 'node' && !$entity->isNew() && $parent_id) {
if (_structural_pages_creates_circular_reference($entity->id(), $parent_id)) {
\Drupal::messenger()->addError(t('Circular reference detected. A page cannot be a parent of itself or its ancestors.'));
// Remove invalid reference.
$entity->set('field_parent_page', NULL);
return;
}
}
// Handle site section based on parent type.
// Some entity types (like user and group) act as context containers themselves.
/** @var \Drupal\structural_pages\ParentEntityHandler\ParentEntityHandlerManagerInterface $handler_manager */
$handler_manager = \Drupal::service('plugin.manager.parent_entity_handler');
if ($handler_manager->clearsSiteSection($parent_entity_type)) {
// Clear field_site_section as the context is the parent entity, not a site section.
$entity->set('field_site_section', NULL);
return;
}
// Inherit field_site_section based on parent type (node or taxonomy_term).
$site_section_id = _structural_pages_get_section_from_parent($parent_entity_type, $parent_id);
if ($site_section_id) {
$entity->set('field_site_section', $site_section_id);
}
}
/**
* Returns the configured site section vocabulary machine name.
*
* @return string
* The vocabulary ID (e.g., 'site_sections').
*/
function _structural_pages_get_vocabulary(): string {
return \Drupal::config('structural_pages.settings')
->get('site_section_vocabulary') ?? 'site_sections';
}
/**
* Gets the site section ID from a parent entity.
*
* @param string $parent_entity_type
* The parent entity type (node or taxonomy_term).
* @param int|string $parent_id
* The parent entity ID.
*
* @return int|string|null
* The site section term ID, or NULL if not found.
*/
function _structural_pages_get_section_from_parent(string $parent_entity_type, int|string $parent_id): int|string|null {
$entity_type_manager = \Drupal::entityTypeManager();
if ($parent_entity_type === 'taxonomy_term') {
// If parent is a taxonomy term, verify it's from the configured vocabulary.
$term = $entity_type_manager->getStorage('taxonomy_term')->load($parent_id);
if ($term instanceof TermInterface && $term->bundle() === _structural_pages_get_vocabulary()) {
return $term->id();
}
return NULL;
}
if ($parent_entity_type === 'node') {
$parent_node = $entity_type_manager->getStorage('node')->load($parent_id);
if (!$parent_node instanceof NodeInterface) {
return NULL;
}
// If parent has field_site_section, use it.
if ($parent_node->hasField('field_site_section') && !$parent_node->get('field_site_section')->isEmpty()) {
return $parent_node->get('field_site_section')->target_id;
}
}
return NULL;
}
/**
* Gets the parent context information for a content_page.
*
* @param \Drupal\node\NodeInterface $node
* The content_page node.
*
* @return array|null
* An array with 'type' and 'entity' keys, or NULL if no parent.
*/
function _structural_pages_get_parent_context(NodeInterface $node): ?array {
if (!$node->hasField('field_parent_page') || $node->get('field_parent_page')->isEmpty()) {
return NULL;
}
$parent_field = $node->get('field_parent_page')->first();
if (!$parent_field) {
return NULL;
}
$parent_entity_type = $parent_field->target_type ?? NULL;
$parent_id = $parent_field->target_id ?? NULL;
if (!$parent_entity_type || !$parent_id) {
return NULL;
}
$parent = \Drupal::entityTypeManager()
->getStorage($parent_entity_type)
->load($parent_id);
if (!$parent) {
return NULL;
}
return [
'type' => $parent_entity_type,
'entity' => $parent,
];
}
/**
* Checks if setting parent_id as parent of node_id would create circular reference.
*
* @param int|string $node_id
* The ID of the node being edited.
* @param int|string $parent_id
* The ID of the potential parent.
*
* @return bool
* TRUE if it would create circular reference, FALSE otherwise.
*/
function _structural_pages_creates_circular_reference(int|string $node_id, int|string $parent_id): bool {
$visited = [];
$current_id = $parent_id;
$node_storage = \Drupal::entityTypeManager()->getStorage('node');
while ($current_id) {
// If we find the original node in the parent chain, it's circular.
if ($current_id == $node_id) {
return TRUE;
}
// Avoid infinite loops in case of corrupted data.
if (isset($visited[$current_id])) {
return TRUE;
}
$visited[$current_id] = TRUE;
// Load the current node and get its parent.
$current_node = $node_storage->load($current_id);
if (!$current_node instanceof NodeInterface ||
!$current_node->hasField('field_parent_page') ||
$current_node->get('field_parent_page')->isEmpty()) {
break;
}
$parent_field = $current_node->get('field_parent_page')->first();
// Only continue checking if parent is also a node.
if (!$parent_field || ($parent_field->target_type ?? NULL) !== 'node') {
break;
}
$current_id = $parent_field->target_id;
}
return FALSE;
}
/**
* Implements hook_theme().
*/
function structural_pages_theme(): array {
return [
'structural_pages_menu' => [
'variables' => [
'ancestor' => NULL,
'ancestor_url' => '',
'tree' => [],
'active_trail' => [],
'show_ancestor_title' => TRUE,
],
],
'structural_pages_menu_tree' => [
'variables' => [
'items' => [],
'active_trail' => [],
'depth' => 0,
],
],
];
}
/**
* Implements hook_token_info().
*/
function structural_pages_token_info(): array {
$info = [];
$info['tokens']['node']['site-section-path'] = [
'name' => t('Site Section Path'),
'description' => t('The hierarchical path of the site section taxonomy (e.g., undergraduate/courses).'),
];
$info['tokens']['term']['hierarchy-path'] = [
'name' => t('Hierarchy Path'),
'description' => t('The hierarchical path of the term including ancestors (e.g., institutional/news).'),
];
return $info;
}
/**
* Implements hook_tokens().
*/
function structural_pages_tokens(string $type, array $tokens, array $data, array $options, $bubbleable_metadata): array {
$replacements = [];
if ($type === 'node' && !empty($data['node'])) {
$node = $data['node'];
foreach ($tokens as $name => $original) {
if ($name === 'site-section-path') {
$replacements[$original] = _structural_pages_get_section_path($node);
}
}
}
if ($type === 'term' && !empty($data['term'])) {
$term = $data['term'];
foreach ($tokens as $name => $original) {
if ($name === 'hierarchy-path') {
$replacements[$original] = _structural_pages_get_term_hierarchy_path($term);
}
}
}
return $replacements;
}
/**
* Gets the hierarchical path of the site section for a node.
*
* @param \Drupal\node\NodeInterface $node
* The node.
*
* @return string
* The section path (e.g., "undergraduate/courses") or empty string.
*/
function _structural_pages_get_section_path(NodeInterface $node): string {
if (!$node->hasField('field_site_section') || $node->get('field_site_section')->isEmpty()) {
return '';
}
$term_id = $node->get('field_site_section')->target_id;
$term_storage = \Drupal::entityTypeManager()->getStorage('taxonomy_term');
$term = $term_storage->load($term_id);
if (!$term) {
return '';
}
// Get all ancestors of the term.
$ancestors = $term_storage->loadAllParents($term_id);
$ancestors = array_reverse($ancestors);
// Build the path using the term names converted to URL.
$path_parts = [];
foreach ($ancestors as $ancestor) {
$path_parts[] = \Drupal::service('pathauto.alias_cleaner')->cleanString($ancestor->getName());
}
return implode('/', $path_parts);
}
/**
* Gets the hierarchical path of a taxonomy term.
*
* @param \Drupal\taxonomy\TermInterface $term
* The taxonomy term.
*
* @return string
* The hierarchical path (e.g., "institutional/news") or empty string.
*/
function _structural_pages_get_term_hierarchy_path(TermInterface $term): string {
$term_storage = \Drupal::entityTypeManager()->getStorage('taxonomy_term');
// Get all ancestors of the term (including itself).
$ancestors = $term_storage->loadAllParents($term->id());
$ancestors = array_reverse($ancestors);
// Build the path using the term names converted to URL.
$path_parts = [];
foreach ($ancestors as $ancestor) {
$path_parts[] = \Drupal::service('pathauto.alias_cleaner')->cleanString($ancestor->getName());
}
return implode('/', $path_parts);
}
/**
* Implements hook_form_FORM_ID_alter() for node_content_page_form.
*/
function structural_pages_form_node_content_page_form_alter(&$form, \Drupal\Core\Form\FormStateInterface $form_state, $form_id) {
_structural_pages_alter_parent_page_form($form, $form_state);
}
/**
* Implements hook_form_FORM_ID_alter() for node_content_page_edit_form.
*/
function structural_pages_form_node_content_page_edit_form_alter(&$form, \Drupal\Core\Form\FormStateInterface $form_state, $form_id) {
_structural_pages_alter_parent_page_form($form, $form_state);
}
/**
* Helper to alter the content_page forms for parent page filtering.
*/
function _structural_pages_alter_parent_page_form(&$form, \Drupal\Core\Form\FormStateInterface $form_state) {
// Hide the native Drupal menu settings tab, since this module generates
// the structural pages menu automatically.
if (isset($form['menu'])) {
$form['menu']['#access'] = FALSE;
}
if (isset($form['field_site_section']) && isset($form['field_parent_page'])) {
// 1. Add AJAX behavior to update parent page options when section changes.
if (isset($form['field_site_section']['widget'])) {
$form['field_site_section']['widget']['#ajax'] = [
'callback' => 'structural_pages_parent_page_ajax_callback',
'wrapper' => 'parent-page-wrapper',
];
}
// Wrap the container where our custom select will go.
$form['structural_pages_wrapper'] = [
'#type' => 'container',
'#attributes' => ['id' => 'parent-page-wrapper'],
'#weight' => $form['field_parent_page']['#weight'] ?? 1,
'#attached' => [
'library' => [
'structural_pages/select2',
],
],
];
// 3. Retrieve the currently selected site_section ID.
$site_section_id = NULL;
// From AJAX state (user just changed it).
$site_section_value = $form_state->getValue('field_site_section');
if (!empty($site_section_value[0]['target_id'])) {
$site_section_id = $site_section_value[0]['target_id'];
}
// From initial form load.
elseif (isset($form['field_site_section']['widget']['#default_value'])) {
$default = $form['field_site_section']['widget']['#default_value'];
if (is_array($default) && !empty($default[0])) {
$site_section_id = $default[0];
}
elseif (is_scalar($default)) {
$site_section_id = $default;
}
}
$current_entity = $form_state->getFormObject()->getEntity();
$current_nid = $current_entity ? $current_entity->id() : NULL;
if ($site_section_id) {
$options = _structural_pages_build_parent_tree_options($site_section_id, $current_nid);
$default_parent = '';
if ($current_entity && !$current_entity->isNew() && !$current_entity->get('field_parent_page')->isEmpty()) {
$default_parent = $current_entity->get('field_parent_page')->target_type . ':' . $current_entity->get('field_parent_page')->target_id;
}
// Hide the original field.
$form['field_parent_page']['#access'] = FALSE;
// Create a new visual field.
$form['structural_pages_wrapper']['custom_parent_page'] = [
'#type' => 'select',
'#title' => t('Parent Page'),
'#options' => $options,
'#default_value' => $default_parent,
'#empty_option' => t('- Raiz da Seção -'),
'#description' => t('Select the parent page within this site section.'),
// Attach a select2/chosen class if available in standard themes
'#attributes' => [
'class' => ['select2-widget', 'chosen-enable'],
'data-placeholder' => t('Search for a parent page...'),
],
];
// We must add a custom submit/validate handler to map our select back to field_parent_page.
array_unshift($form['#validate'], 'structural_pages_custom_parent_validate');
} else {
// If no section chosen yet, hide parent page completely so they pick a section first.
$form['field_parent_page']['#access'] = FALSE;
$form['structural_pages_wrapper']['custom_parent_page'] = [
'#type' => 'markup',
'#markup' => '<p><em>' . t('Por favor, selecione uma Site Section primeiro.') . '</em></p>',
];
}
}
}
/**
* Validation callback to map the select list back into dynamic_entity_reference.
*/
function structural_pages_custom_parent_validate(&$form, \Drupal\Core\Form\FormStateInterface $form_state) {
$custom_val = $form_state->getValue('custom_parent_page');
if (!empty($custom_val)) {
list($type, $id) = explode(':', $custom_val);
$form_state->setValue('field_parent_page', [
['target_id' => $id, 'target_type' => $type]
]);
} else {
$form_state->setValue('field_parent_page', []);
}
}
/**
* AJAX callback to replace the parent page wrapper.
*/
function structural_pages_parent_page_ajax_callback(array &$form, \Drupal\Core\Form\FormStateInterface $form_state) {
return $form['structural_pages_wrapper'];
}
/**
* Builds a hierarchical tree array of content_pages for a given section.
*/
function _structural_pages_build_parent_tree_options($site_section_id, $current_node_id = NULL) {
$options = [];
// Add the Section's Taxonomy Term as a Root option
$term = \Drupal::entityTypeManager()->getStorage('taxonomy_term')->load($site_section_id);
if ($term) {
$options['taxonomy_term:' . $site_section_id] = '< Raiz (' . $term->getName() . ') >';
}
$query = \Drupal::entityQuery('node')
->condition('type', 'content_page')
->condition('field_site_section', $site_section_id)
->accessCheck(TRUE)
->sort('title', 'ASC');
$nids = $query->execute();
if (empty($nids)) {
return $options;
}
$nodes = \Drupal::entityTypeManager()->getStorage('node')->loadMultiple($nids);
$children = [];
$roots = [];
foreach ($nodes as $nid => $node) {
if ($nid == $current_node_id) {
continue; // Skip self
}
$parent_id = NULL;
$parent_type = NULL;
if (!$node->get('field_parent_page')->isEmpty()) {
$parent_id = $node->get('field_parent_page')->target_id;
$parent_type = $node->get('field_parent_page')->target_type;
}
// If the parent is another content_page in our list, it's a child.
if ($parent_type === 'node' && isset($nodes[$parent_id])) {
$children[$parent_id][] = $nid;
} else {
// It's a root (parent is a taxonomy term, a section_page, or empty)
$roots[] = $nid;
}
}
// Try to sort roots alphabetically
usort($roots, function($a, $b) use ($nodes) {
return strcmp($nodes[$a]->getTitle(), $nodes[$b]->getTitle());
});
$build_options = function($nids, $depth) use (&$build_options, &$options, $nodes, $children) {
// We use non-breaking spaces and hyphens for a tree view look
$prefix = str_repeat('— ', $depth);
foreach ($nids as $nid) {
$options['node:' . $nid] = $prefix . $nodes[$nid]->getTitle();
if (isset($children[$nid])) {
// Sort children alphabetically too
$child_nids = $children[$nid];
usort($child_nids, function($a, $b) use ($nodes) {
return strcmp($nodes[$a]->getTitle(), $nodes[$b]->getTitle());
});
$build_options($child_nids, $depth + 1);
}
}
};
$build_options($roots, 0);
return $options;
}
/**
* Implements hook_preprocess_node().
*/
function structural_pages_preprocess_node(array &$variables): void {
// Forcefully remove the "submitted by" author and date information
// for content pages, regardless of theme settings.
if (isset($variables['node']) && $variables['node']->bundle() === 'content_page') {
$variables['display_submitted'] = FALSE;
}
}
/**
* Implements hook_views_post_execute().
*/
function structural_pages_views_post_execute(\Drupal\views\ViewExecutable $view) {
// Fallback for child_pages view: if empty, show sibling pages instead.
if ($view->id() === 'child_pages' && empty($view->result)) {
$args = $view->args;
if (!empty($args[0]) && is_numeric($args[0])) {
$node_storage = \Drupal::entityTypeManager()->getStorage('node');
$current_node = $node_storage->load($args[0]);
if ($current_node instanceof \Drupal\node\NodeInterface && $current_node->bundle() === 'content_page') {
$query = \Drupal::entityQuery('node')
->condition('type', 'content_page')
->condition('status', 1)
->accessCheck(TRUE)
->sort('title', 'ASC');
$parent_id = NULL;
if (!$current_node->get('field_parent_page')->isEmpty()) {
$parent_field = $current_node->get('field_parent_page')->first();
if (($parent_field->target_type ?? '') === 'node') {
$parent_id = $parent_field->target_id;
}
}
if ($parent_id) {
// Has a node parent. Siblings are nodes with the same parent.
$query->condition('field_parent_page.target_id', $parent_id);
$query->condition('field_parent_page.target_type', 'node');
} else {
// Root page. Siblings are other root pages in the same site section.
$query->notExists('field_parent_page');
if (!$current_node->get('field_site_section')->isEmpty()) {
$query->condition('field_site_section.target_id', $current_node->get('field_site_section')->target_id);
}
}
$nids = $query->execute();
if (!empty($nids)) {
// Load the sibling nodes
$siblings = $node_storage->loadMultiple($nids);
$index = 0;
$view->result = [];
foreach ($siblings as $nid => $sibling) {
$row = new \Drupal\views\ResultRow();
$row->_entity = $sibling;
$row->nid = $nid;
$row->index = $index++;
$view->result[] = $row;
}
$view->total_rows = count($view->result);
// Optionally alter the title to indicate these are siblings
$view->setTitle(t('Páginas do mesmo nível'));
// Remove empty text since we now have results
$view->empty = [];
}
}
}
}
}