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 (_site_structure_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\site_structure\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 = _site_structure_get_section_from_parent($parent_entity_type, $parent_id); if ($site_section_id) { $entity->set('field_site_section', $site_section_id); } } /** * 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 _site_structure_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 site_section vocabulary. $term = $entity_type_manager->getStorage('taxonomy_term')->load($parent_id); if ($term instanceof TermInterface && $term->bundle() === 'site_section') { 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 _site_structure_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 _site_structure_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 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(). */ function site_structure_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 site_structure_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] = _site_structure_get_section_path($node); } } } if ($type === 'term' && !empty($data['term'])) { $term = $data['term']; foreach ($tokens as $name => $original) { if ($name === 'hierarchy-path') { $replacements[$original] = _site_structure_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 _site_structure_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 _site_structure_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); }