bundle() !== 'content_page') { return; } // Root page: field_site_section is managed by the editor, nothing to inherit. 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_id = $parent_field->target_id ?? NULL; if (!$parent_id) { return; } // Circular reference validation. if (!$entity->isNew()) { 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.')); $entity->set('field_parent_page', NULL); return; } } // Inherit field_site_section (DER value: target_type + target_id) from the // parent content_page so sibling pages always share the same domain root. $parent_node = \Drupal::entityTypeManager()->getStorage('node')->load($parent_id); if (!$parent_node instanceof NodeInterface) { return; } if (!$parent_node->hasField('field_site_section') || $parent_node->get('field_site_section')->isEmpty()) { return; } $parent_fss = $parent_node->get('field_site_section')->first(); $entity->set('field_site_section', [ 'target_type' => $parent_fss->target_type ?? 'taxonomy_term', 'target_id' => $parent_fss->target_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'; } /** * 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']['node']['content-page-ancestors'] = [ 'name' => t('Content Page Ancestors Path'), 'description' => t('Array of ancestor path segments for a content_page node (domain + parent pages). Use [node:content-page-ancestors:join-path] in Pathauto patterns to get a slash-separated path (e.g., user/joao/pesquisa).'), 'type' => 'array', ]; $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 ($name === 'content-page-ancestors') { $path = _structural_pages_get_content_page_ancestors($node); $replacements[$original] = !empty($path) ? array_values(array_filter(explode('/', $path))) : ''; } } // Handle [node:content-page-ancestors:join-path] and other chained tokens. // Pathauto's [array:join-path] cleans each segment individually and joins // with '/', preserving slashes. The token ends in ':join-path]' which is // in Pathauto's safe_tokens list, so the result is NOT re-cleaned. if ($ancestors_tokens = \Drupal::token()->findWithPrefix($tokens, 'content-page-ancestors')) { $path = _structural_pages_get_content_page_ancestors($node); if (!empty($path)) { $segments = array_values(array_filter(explode('/', $path))); $replacements += \Drupal::token()->generate( 'array', $ancestors_tokens, ['array' => $segments], $options, $bubbleable_metadata ); } } } 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 ''; } $fss = $node->get('field_site_section')->first(); // Only taxonomy_term domains have a hierarchical term path. if (($fss->target_type ?? 'taxonomy_term') !== 'taxonomy_term') { return ''; } $term_id = $fss->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 full ancestor path for a content_page node. * * Traverses the field_parent_page chain to the root, then prepends the domain * entity's URL alias (from field_site_section). Intended as a Pathauto token * prefix: configure the pattern as "[node:content-page-ancestors]/[node:title]". * * Examples: * - Root page under user "joao" → "user/joao" * - Child of "Pesquisa" under user "joao" → "user/joao/pesquisa" * - Root page under term "graduacao/disciplinas" → "graduacao/disciplinas" * * @param \Drupal\node\NodeInterface $node * The content_page node. * * @return string * The ancestor path without leading/trailing slashes, or empty string. */ function _structural_pages_get_content_page_ancestors(NodeInterface $node): string { if ($node->bundle() !== 'content_page') { return ''; } $node_storage = \Drupal::entityTypeManager()->getStorage('node'); $alias_cleaner = \Drupal::service('pathauto.alias_cleaner'); $path_alias_manager = \Drupal::service('path_alias.manager'); // Walk up the field_parent_page chain collecting parent titles. $parent_slugs = []; $visited = []; $current = $node; $max = 50; while ($max-- > 0) { if (isset($visited[$current->id()])) { break; } $visited[$current->id()] = TRUE; if (!$current->hasField('field_parent_page') || $current->get('field_parent_page')->isEmpty()) { // Reached the root content_page: resolve domain from field_site_section. if (!$current->hasField('field_site_section') || $current->get('field_site_section')->isEmpty()) { break; } $fss = $current->get('field_site_section')->first(); $domain_type = $fss->target_type ?? 'taxonomy_term'; $domain_id = $fss->target_id; $domain = \Drupal::entityTypeManager()->getStorage($domain_type)->load($domain_id); if (!$domain || !$domain->hasLinkTemplate('canonical')) { break; } // Build the system path for the domain entity. // Constructed directly for known entity types to avoid URL generator // context issues that can occur inside hook_tokens(). $domain_system_path = match ($domain_type) { 'user' => '/user/' . $domain_id, 'taxonomy_term' => '/taxonomy/term/' . $domain_id, default => (static function () use ($domain): string { try { return '/' . $domain->toUrl('canonical')->getInternalPath(); } catch (\Exception $e) { return ''; } })(), }; if (empty($domain_system_path)) { break; } // Resolve to alias if one exists. $domain_alias = $path_alias_manager->getAliasByPath($domain_system_path); $domain_path = ltrim($domain_alias, '/'); // Build: domain/parent2/parent1 (parents were collected innermost-first). $parts = array_merge([$domain_path], array_reverse($parent_slugs)); return implode('/', array_filter($parts)); } // Collect this node's parent's title (we traverse before adding current). $parent_field = $current->get('field_parent_page')->first(); $parent_id = $parent_field->target_id ?? NULL; if (!$parent_id) { break; } $parent = $node_storage->load($parent_id); if (!$parent) { break; } // Add the parent's slug to the list (innermost first, reversed later). $parent_slugs[] = $alias_cleaner->cleanString($parent->getTitle()); $current = $parent; } return ''; } /** * 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. * * Architecture: * - field_site_section (DER): domain root — taxonomy_term, user, or event. * - field_parent_page (DER, node-only): parent content_page within the domain, * or empty for a root page. * * The native field_parent_page widget is replaced by a custom hierarchical * select that lists only content_pages sharing the same field_site_section * value. A sentinel option '__root__' represents an empty parent (root page). * Query params ?domain_type=&domain_id= (or the aliases * ?parent_section_type=&parent_id=) pre-fill field_site_section * when creating a new page from an external domain context (e.g., user). */ function _structural_pages_alter_parent_page_form(&$form, \Drupal\Core\Form\FormStateInterface $form_state) { // Hide the native Drupal menu settings tab. if (isset($form['menu'])) { $form['menu']['#access'] = FALSE; } // Conditional visibility: field_menu_title and field_weight are only // relevant when field_show_in_menu is checked. $show_in_menu_selector = 'input[name="field_show_in_menu[value]"]'; if (isset($form['field_menu_title'])) { $form['field_menu_title']['#states'] = [ 'visible' => [$show_in_menu_selector => ['checked' => TRUE]], ]; } if (!isset($form['field_site_section']) || !isset($form['field_parent_page'])) { return; } $current_entity = $form_state->getFormObject()->getEntity(); // Pre-fill field_site_section for new nodes created from a domain context. if ($current_entity->isNew()) { $request = \Drupal::request(); $qp_type = $request->query->get('domain_type') ?? $request->query->get('parent_section_type'); $qp_id = $request->query->get('domain_id') ?? $request->query->get('parent_id'); if ($qp_type && $qp_id && $qp_type !== 'node') { $domain_entity = \Drupal::entityTypeManager()->getStorage($qp_type)->load($qp_id); if ($domain_entity) { // Set the entity value (used on AJAX rebuild and on save). $current_entity->set('field_site_section', [ 'target_type' => $qp_type, 'target_id' => $qp_id, ]); // Also update the widget elements directly: hook_form_alter runs after // the DER widget has already been built from the original (empty) entity, // so #default_value in the rendered elements needs to be set explicitly. if (isset($form['field_site_section']['widget'][0]['target_type'])) { $form['field_site_section']['widget'][0]['target_type']['#default_value'] = $qp_type; } if (isset($form['field_site_section']['widget'][0]['target_id'])) { $form['field_site_section']['widget'][0]['target_id']['#default_value'] = $domain_entity; } } } } // Add AJAX so changing field_site_section rebuilds the parent page options. $ajax = [ 'callback' => 'structural_pages_parent_page_ajax_callback', 'wrapper' => 'parent-page-wrapper', 'event' => 'change', ]; if (isset($form['field_site_section']['widget'][0]['target_type'])) { $form['field_site_section']['widget'][0]['target_type']['#ajax'] = $ajax; } if (isset($form['field_site_section']['widget'][0]['target_id'])) { $form['field_site_section']['widget'][0]['target_id']['#ajax'] = $ajax; } // Fallback for single-type widgets (entity_reference-style DER or pre-DER). if (!isset($form['field_site_section']['widget'][0]['target_type']) && !isset($form['field_site_section']['widget'][0]['target_id'])) { $form['field_site_section']['widget']['#ajax'] = $ajax; } // Wrapper container replaced by AJAX. $form['structural_pages_wrapper'] = [ '#type' => 'container', '#attributes' => ['id' => 'parent-page-wrapper'], '#weight' => $form['field_parent_page']['#weight'] ?? 1, '#attached' => ['library' => ['structural_pages/select2']], ]; // Hide the native DER widget for field_parent_page; we replace it below. $form['field_parent_page']['#access'] = FALSE; // Determine the active domain (type + id) from field_site_section. [$domain_type, $domain_id] = _structural_pages_get_form_domain($form, $form_state, $current_entity); if ($domain_type && $domain_id) { $domain_entity = \Drupal::entityTypeManager()->getStorage($domain_type)->load($domain_id); $domain_label = $domain_entity ? $domain_entity->label() : t('Domínio'); $current_nid = (!$current_entity->isNew()) ? (int) $current_entity->id() : NULL; $options = _structural_pages_build_parent_tree_options($domain_type, $domain_id, $current_nid); $options = ['__root__' => '< ' . t('Raiz (@label)', ['@label' => $domain_label]) . ' >'] + $options; // Determine default selection. $default_parent = '__root__'; if (!$current_entity->isNew() && !$current_entity->get('field_parent_page')->isEmpty()) { $pf = $current_entity->get('field_parent_page')->first(); if ($pf && $pf->target_id) { $key = 'node:' . $pf->target_id; if (isset($options[$key])) { $default_parent = $key; } } } $form['structural_pages_wrapper']['custom_parent_page'] = [ '#type' => 'select', '#title' => t('Parent Page'), '#options' => $options, '#default_value' => $default_parent, '#description' => t('Select the parent page within this domain. Select Root to create a top-level page.'), '#attributes' => [ 'class' => ['select2-widget', 'chosen-enable'], 'data-placeholder' => t('Search for a parent page...'), ], ]; array_unshift($form['#validate'], 'structural_pages_custom_parent_validate'); } else { $form['structural_pages_wrapper']['custom_parent_page'] = [ '#type' => 'markup', '#markup' => '

' . t('Por favor, selecione a Seção do Site primeiro.') . '

', ]; } } /** * Returns the domain [type, id] from the form state or the entity being edited. * * Priority: * 1. AJAX rebuild state (user just changed field_site_section). * 2. Saved field_site_section value on the entity being edited. * 3. Legacy widget #default_value (pre-DER fallback). * * @return array{0: string|null, 1: int|string|null} */ function _structural_pages_get_form_domain(array &$form, \Drupal\Core\Form\FormStateInterface $form_state, \Drupal\Core\Entity\EntityInterface $entity): array { // 1. AJAX state. $fss_value = $form_state->getValue('field_site_section'); if (!empty($fss_value[0]['target_id'])) { return [ $fss_value[0]['target_type'] ?? 'taxonomy_term', $fss_value[0]['target_id'], ]; } // 2. Saved entity value. if ($entity->hasField('field_site_section') && !$entity->get('field_site_section')->isEmpty()) { $fss = $entity->get('field_site_section')->first(); return [ $fss->target_type ?? 'taxonomy_term', $fss->target_id, ]; } // 3. Legacy widget default value (entity_reference before DER migration). if (isset($form['field_site_section']['widget']['#default_value'])) { $default = $form['field_site_section']['widget']['#default_value']; if (is_array($default) && !empty($default[0])) { return ['taxonomy_term', $default[0]]; } if (is_scalar($default) && $default) { return ['taxonomy_term', $default]; } } return [NULL, NULL]; } /** * Validation callback: maps the custom select back to field_parent_page. * * '__root__' or empty → field_parent_page = [] (root page, no parent). * 'node:' → field_parent_page = [{target_id: id, target_type: node}]. */ 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) || $custom_val === '__root__') { $form_state->setValue('field_parent_page', []); } else { [, $id] = explode(':', $custom_val, 2); $form_state->setValue('field_parent_page', [ ['target_id' => $id, 'target_type' => 'node'], ]); } } /** * 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 domain. * * Queries all content_pages with field_site_section matching [$domain_type, * $domain_id], then arranges them in a tree keyed as 'node:'. * * @param string $domain_type * The domain entity type (e.g. 'taxonomy_term', 'user'). * @param int|string $domain_id * The domain entity ID. * @param int|string|null $current_node_id * The node being edited (excluded from options to prevent self-reference). * * @return array * Associative array 'node:' => indented title. */ function _structural_pages_build_parent_tree_options(string $domain_type, int|string $domain_id, $current_node_id = NULL): array { $options = []; $query = \Drupal::entityQuery('node') ->condition('type', 'content_page') ->accessCheck(TRUE) ->sort('title', 'ASC'); _structural_pages_apply_domain_condition($query, $domain_type, $domain_id); $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; } $parent_id = NULL; if (!$node->get('field_parent_page')->isEmpty()) { $parent_id = $node->get('field_parent_page')->target_id; } if ($parent_id && isset($nodes[$parent_id])) { $children[$parent_id][] = $nid; } else { $roots[] = $nid; } } usort($roots, fn($a, $b) => strcmp($nodes[$a]->getTitle(), $nodes[$b]->getTitle())); $build_options = function ($nids, $depth) use (&$build_options, &$options, $nodes, $children) { $prefix = str_repeat('— ', $depth); foreach ($nids as $nid) { $options['node:' . $nid] = $prefix . $nodes[$nid]->getTitle(); if (isset($children[$nid])) { $child_nids = $children[$nid]; usort($child_nids, fn($a, $b) => strcmp($nodes[$a]->getTitle(), $nodes[$b]->getTitle())); $build_options($child_nids, $depth + 1); } } }; $build_options($roots, 0); return $options; } /** * Applies domain conditions to a content_page entity query. * * Uses a direct database subquery on node__field_site_section to filter by * both target_type and target_id. This avoids relying on DER's entity query * integration (which may throw "Invalid specifier 'target_type'" depending on * the installed DER version and Drupal's internal caching). * * @param \Drupal\Core\Entity\Query\QueryInterface $query * The entity query to modify. * @param string $domain_type * The domain entity type (e.g. 'taxonomy_term', 'user'). * @param int|string $domain_id * The domain entity ID. */ function _structural_pages_apply_domain_condition(\Drupal\Core\Entity\Query\QueryInterface $query, string $domain_type, int|string $domain_id): void { $database = \Drupal::database(); $schema = $database->schema(); if ($schema->fieldExists('node__field_site_section', 'field_site_section_target_type')) { // DER storage: query the field table directly to filter by both columns. $nids = $database->select('node__field_site_section', 'fss') ->fields('fss', ['entity_id']) ->condition('fss.field_site_section_target_type', $domain_type) ->condition('fss.field_site_section_target_id', $domain_id) ->condition('fss.deleted', 0) ->execute() ->fetchCol(); // Add a condition that will never match if no results found. $query->condition('nid', empty($nids) ? [0] : $nids, 'IN'); } else { // Legacy entity_reference storage (before update hook 10011 runs): // all references are taxonomy_terms, target_id alone is sufficient. $query->condition('field_site_section', $domain_id); } } /** * 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. // field_parent_page only points to nodes (post-migration), so // filtering by target_id alone is sufficient. $query->condition('field_parent_page.target_id', $parent_id); } 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()) { $fss = $current_node->get('field_site_section')->first(); $domain_type = $fss->target_type ?? 'taxonomy_term'; $domain_id = $fss->target_id; _structural_pages_apply_domain_condition($query, $domain_type, $domain_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 = []; } } } } }