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>
This commit is contained in:
2026-02-03 19:55:00 -03:00
commit 8a42a6f1c1
31 changed files with 2633 additions and 0 deletions

View File

@@ -0,0 +1,320 @@
<?php
declare(strict_types=1);
namespace Drupal\site_structure\Breadcrumb;
use Drupal\Core\Breadcrumb\Breadcrumb;
use Drupal\Core\Breadcrumb\BreadcrumbBuilderInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Link;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\node\NodeInterface;
/**
* Provides a breadcrumb builder for section_page and content_page content types.
*/
class SectionBreadcrumbBuilder implements BreadcrumbBuilderInterface {
use StringTranslationTrait;
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected EntityTypeManagerInterface $entityTypeManager;
/**
* Content types that this builder applies to.
*
* @var array
*/
protected array $applicableBundles = ['section_page', 'content_page'];
/**
* Constructs a SectionBreadcrumbBuilder object.
*
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
*/
public function __construct(EntityTypeManagerInterface $entity_type_manager) {
$this->entityTypeManager = $entity_type_manager;
}
/**
* {@inheritdoc}
*/
public function applies(RouteMatchInterface $route_match): bool {
$node = $route_match->getParameter('node');
if ($node instanceof NodeInterface) {
return in_array($node->bundle(), $this->applicableBundles, TRUE);
}
return FALSE;
}
/**
* {@inheritdoc}
*/
public function build(RouteMatchInterface $route_match): Breadcrumb {
$breadcrumb = new Breadcrumb();
$breadcrumb->addCacheContexts(['route']);
$node = $route_match->getParameter('node');
if (!$node instanceof NodeInterface) {
return $breadcrumb;
}
$breadcrumb->addCacheableDependency($node);
// Add "Home" as first item.
$breadcrumb->addLink(Link::createFromRoute($this->t('Home'), '<front>'));
// Determine the context type for content_page.
$context = $this->getParentContext($node);
if ($context) {
switch ($context['type']) {
case 'user':
// User context: Home > User Name > ... > Current Page.
$this->addUserBreadcrumb($breadcrumb, $context['entity']);
break;
case 'group':
// Group context: Home > Group Name > ... > Current Page.
$this->addGroupBreadcrumb($breadcrumb, $context['entity']);
break;
default:
// Node or taxonomy context: use site section.
if ($node->hasField('field_site_section') && !$node->get('field_site_section')->isEmpty()) {
$term_id = $node->get('field_site_section')->target_id;
$this->addTaxonomyBreadcrumbs($breadcrumb, $term_id);
}
break;
}
}
else {
// No parent context, check for site section directly.
if ($node->hasField('field_site_section') && !$node->get('field_site_section')->isEmpty()) {
$term_id = $node->get('field_site_section')->target_id;
$this->addTaxonomyBreadcrumbs($breadcrumb, $term_id);
}
}
// For content_page, add node parent hierarchy.
if ($node->bundle() === 'content_page') {
$this->addParentBreadcrumbs($breadcrumb, $node);
}
return $breadcrumb;
}
/**
* Gets the root parent context for a content_page.
*
* Traverses up the parent chain to find the root context (user, group, or taxonomy).
*
* @param \Drupal\node\NodeInterface $node
* The content_page node.
*
* @return array|null
* An array with 'type' and 'entity' keys, or NULL if no context.
*/
protected function getParentContext(NodeInterface $node): ?array {
if ($node->bundle() !== 'content_page') {
return NULL;
}
$visited = [];
$current = $node;
while ($current instanceof NodeInterface &&
$current->hasField('field_parent_page') &&
!$current->get('field_parent_page')->isEmpty()) {
$parent_field = $current->get('field_parent_page')->first();
if (!$parent_field) {
break;
}
$parent_entity_type = $parent_field->target_type ?? NULL;
$parent_id = $parent_field->target_id ?? NULL;
if (!$parent_entity_type || !$parent_id) {
break;
}
// Avoid infinite loops.
$visit_key = $parent_entity_type . ':' . $parent_id;
if (isset($visited[$visit_key])) {
break;
}
$visited[$visit_key] = TRUE;
$parent = $this->entityTypeManager
->getStorage($parent_entity_type)
->load($parent_id);
if (!$parent) {
break;
}
// If parent is user or group, that's our context.
if (in_array($parent_entity_type, ['user', 'group'])) {
return [
'type' => $parent_entity_type,
'entity' => $parent,
];
}
// If parent is taxonomy_term, that's our context (handled via site_section).
if ($parent_entity_type === 'taxonomy_term') {
return [
'type' => 'taxonomy_term',
'entity' => $parent,
];
}
// If parent is a node, continue traversing.
if ($parent instanceof NodeInterface) {
$current = $parent;
}
else {
break;
}
}
return NULL;
}
/**
* Adds user breadcrumb.
*
* @param \Drupal\Core\Breadcrumb\Breadcrumb $breadcrumb
* The breadcrumb object.
* @param \Drupal\Core\Entity\EntityInterface $user
* The user entity.
*/
protected function addUserBreadcrumb(Breadcrumb $breadcrumb, EntityInterface $user): void {
$breadcrumb->addCacheableDependency($user);
$breadcrumb->addLink($user->toLink());
}
/**
* Adds group breadcrumb.
*
* @param \Drupal\Core\Breadcrumb\Breadcrumb $breadcrumb
* The breadcrumb object.
* @param \Drupal\Core\Entity\EntityInterface $group
* The group entity.
*/
protected function addGroupBreadcrumb(Breadcrumb $breadcrumb, EntityInterface $group): void {
$breadcrumb->addCacheableDependency($group);
$breadcrumb->addLink($group->toLink());
}
/**
* Adds breadcrumbs based on taxonomy hierarchy.
*
* @param \Drupal\Core\Breadcrumb\Breadcrumb $breadcrumb
* The breadcrumb object.
* @param int|string $term_id
* The taxonomy term ID.
*/
protected function addTaxonomyBreadcrumbs(Breadcrumb $breadcrumb, int|string $term_id): void {
$term_storage = $this->entityTypeManager->getStorage('taxonomy_term');
$ancestors = $term_storage->loadAllParents($term_id);
$ancestors = array_reverse($ancestors);
foreach ($ancestors as $ancestor) {
$breadcrumb->addCacheableDependency($ancestor);
$breadcrumb->addLink($ancestor->toLink());
}
}
/**
* Adds breadcrumbs based on content_page parent hierarchy.
*
* Only adds node parents to the breadcrumb, not user/group/taxonomy
* which are handled separately.
*
* @param \Drupal\Core\Breadcrumb\Breadcrumb $breadcrumb
* The breadcrumb object.
* @param \Drupal\node\NodeInterface $node
* The current node.
*/
protected function addParentBreadcrumbs(Breadcrumb $breadcrumb, NodeInterface $node): void {
$parents = $this->getNodeParents($node);
$parents = array_reverse($parents);
foreach ($parents as $parent) {
$breadcrumb->addCacheableDependency($parent);
$breadcrumb->addLink($parent->toLink());
}
}
/**
* Gets all node parents of a content_page.
*
* Stops when encountering a non-node parent (user, group, taxonomy_term).
*
* @param \Drupal\node\NodeInterface $node
* The node.
*
* @return \Drupal\node\NodeInterface[]
* Array of parent nodes, from closest to farthest.
*/
protected function getNodeParents(NodeInterface $node): array {
$parents = [];
$visited = [];
$current = $node;
while ($current instanceof NodeInterface &&
$current->hasField('field_parent_page') &&
!$current->get('field_parent_page')->isEmpty()) {
$parent_field = $current->get('field_parent_page')->first();
if (!$parent_field) {
break;
}
$parent_entity_type = $parent_field->target_type ?? NULL;
$parent_id = $parent_field->target_id ?? NULL;
if (!$parent_entity_type || !$parent_id) {
break;
}
// Only continue if parent is a node.
if ($parent_entity_type !== 'node') {
break;
}
// Avoid infinite loops.
if (isset($visited[$parent_id])) {
break;
}
$visited[$parent_id] = TRUE;
$parent = $this->entityTypeManager
->getStorage('node')
->load($parent_id);
if (!$parent instanceof NodeInterface) {
break;
}
$parents[] = $parent;
$current = $parent;
}
return $parents;
}
}