Files
structural_pages/site_structure.module
Quintino A. G. Souza 8a42a6f1c1 Initial commit: Site Structure module for Drupal
Drupal module that provides hierarchical site structure management
with support for sections, categories, and content items. Includes
path aliases with tokens, breadcrumb integration, and admin interface.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 19:55:00 -03:00

299 lines
8.5 KiB
Plaintext

<?php
/**
* @file
* Primary module hooks for Site Structure 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 site_structure_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 (_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.
// For user and group, we don't set field_site_section - the context is the entity itself.
if (in_array($parent_entity_type, ['user', 'group'])) {
// Clear field_site_section as the context is the user/group, 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_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);
}