diff --git a/modules/tide_breadcrumbs/css/breadcrumb.css b/modules/tide_breadcrumbs/css/breadcrumb.css new file mode 100644 index 00000000..dfae3398 --- /dev/null +++ b/modules/tide_breadcrumbs/css/breadcrumb.css @@ -0,0 +1,34 @@ +/** + * Styles for the computed breadcrumb trail. + */ +.custom-breadcrumb-container { + margin-bottom: 1.5rem; + padding: 0.5rem 0; + border-bottom: 1px solid #eee; +} + +.custom-breadcrumb-container strong { + margin-right: 10px; + color: #333; +} + +.custom-breadcrumb { + display: inline; +} + +.custom-breadcrumb a { + text-decoration: none; + font-weight: 500; + color: #0056b3; +} + +.custom-breadcrumb a:hover { + text-decoration: underline; +} + +.custom-breadcrumb .divider { + color: #888; + padding: 0 8px; + font-size: 0.9em; + vertical-align: middle; +} diff --git a/modules/tide_breadcrumbs/src/BreadcrumbComputedField.php b/modules/tide_breadcrumbs/src/BreadcrumbComputedField.php new file mode 100644 index 00000000..9805c65f --- /dev/null +++ b/modules/tide_breadcrumbs/src/BreadcrumbComputedField.php @@ -0,0 +1,63 @@ +getEntity(); + // Ensure we are working with a node entity. + if (!$node instanceof NodeInterface) { + return; + } + + /** @var \Drupal\tide_breadcrumbs\TideBreadcrumbBuilder $breadcrumb_service */ + $breadcrumb_service = \Drupal::service('tide_breadcrumbs.breadcrumb_builder'); + $trail = $breadcrumb_service->buildFullTrail($node); + + if (!empty($trail) && is_array($trail)) { + // Reset the list to ensure no stale data exists during computation. + $this->list = []; + + foreach ($trail as $delta => $item) { + // Create an item for each crumb in the trail. + $this->list[$delta] = $this->createItem($delta, [ + 'title' => $item['title'], + 'url' => $item['url'], + ]); + } + } + } + + /** + * {@inheritdoc} + * + * Overridden to ensure the value is computed before being returned. + */ + public function getValue() { + if (!$this->valueComputed) { + $this->computeValue(); + } + return parent::getValue(); + } + +} diff --git a/modules/tide_breadcrumbs/src/TideBreadcrumbBuilder.php b/modules/tide_breadcrumbs/src/TideBreadcrumbBuilder.php new file mode 100644 index 00000000..183395f8 --- /dev/null +++ b/modules/tide_breadcrumbs/src/TideBreadcrumbBuilder.php @@ -0,0 +1,451 @@ +menuTree = $menu_tree; + $this->entityTypeManager = $entity_type_manager; + } + + /** + * Main entry point: Chained Section Logic with Taxonomy Parent Discovery. + * + * Builds a full trail starting from the Primary Site home, relaying through + * all relevant Section Site homes, and finally finding the node's position + * within its specific menu. + * + * @param \Drupal\node\NodeInterface $node + * The node for which to build the trail. + * + * @return array + * An array of breadcrumb items, each containing 'title' and 'url'. + */ + public function buildFullTrail(NodeInterface $node) { + $nodeTitle = $node->getTitle() ?: 'Title not found'; + // If the node is being created or cloned, return a simplified trail. + if ($node->isNew()) { + $primary_site_term = $node->get('field_node_primary_site')->entity; + + // Default to site root if no primary site is selected yet. + $home_crumb = ['title' => 'Home', 'url' => '/']; + + if ($primary_site_term instanceof TermInterface) { + $home_crumb = $this->getPrimaryHomeLink($primary_site_term); + } + + return [ + $home_crumb, + ]; + } + + $targetNid = (string) $node->id(); + + // Get all relevant section terms (including ancestors up to Level 2). + $section_terms = $this->getOrderedSectionTerms($node); + $primary_site_term = $node->get('field_node_primary_site')->entity; + + $chained_trail = []; + $found_node_in_menu = FALSE; + + if ($primary_site_term instanceof TermInterface) { + $primary_menu_id = $primary_site_term->get('field_site_main_menu')->target_id; + + // Start with Absolute Primary Home. + $chained_trail[] = $this->getPrimaryHomeLink($primary_site_term); + + // THE RELAY: Chain from Parent -> Child -> Grandchild. + foreach ($section_terms as $term) { + $menu_id = $term->get('field_site_main_menu')->target_id; + + if (!$menu_id) { + continue; + } + + // Search current section menu for the node. + $node_trail_in_this_menu = $this->getTrailFromMenu($menu_id, $targetNid, $primary_menu_id); + + if ($node_trail_in_this_menu) { + // If this is the start of the chain, bridge from Primary Menu. + if (count($chained_trail) === 1 && $primary_menu_id) { + $bridge = $this->getTrailByUrl($primary_menu_id, $node_trail_in_this_menu[0]['url'], $primary_menu_id); + if ($bridge) { + $chained_trail = array_merge($chained_trail, $bridge); + } + } + + $chained_trail = array_merge($chained_trail, $node_trail_in_this_menu); + $found_node_in_menu = TRUE; + break; + } + else { + // Node not here, add Section Home and continue relay. + $section_root = $this->getMenuRootByWeight($menu_id, $primary_menu_id); + if ($section_root) { + if (count($chained_trail) === 1 && $primary_menu_id) { + $bridge = $this->getTrailByUrl($primary_menu_id, $section_root['url'], $primary_menu_id); + if ($bridge) { + $chained_trail = array_merge($chained_trail, $bridge); + } + } + $chained_trail[] = $section_root; + } + } + } + + // FALLBACK: Node not found in any Section Menu. + if (!$found_node_in_menu) { + if (count($chained_trail) === 1 && $primary_menu_id) { + // When falling back to the primary menu, skip adding the root crumb. + // This prevents showing the 1st menu item directly after "Home". + $primary_search = $this->getTrailFromMenu($primary_menu_id, $targetNid, $primary_menu_id, TRUE); + if ($primary_search) { + $chained_trail = array_merge($chained_trail, $primary_search); + $found_node_in_menu = TRUE; + } + } + + if (!$found_node_in_menu) { + $chained_trail[] = ['title' => $nodeTitle, 'url' => $node->toUrl()->toString()]; + } + } + } + + // Deduplicate URLs. + $chained_trail = $this->deduplicateTrail($chained_trail); + + // Remove the current page item from the breadcrumb trail. + if (!empty($chained_trail)) { + $last_item = end($chained_trail); + $nodeUrl = rtrim($node->toUrl()->toString(), '/'); + $lastItemUrl = rtrim($last_item['url'], '/'); + + // Check if the last item is the current node by URL or Title. + if ($lastItemUrl === $nodeUrl || $last_item['title'] === $nodeTitle) { + array_pop($chained_trail); + } + } + + return $chained_trail; + } + + /** + * Crawls taxonomy to find all parents between tagged term and Primary Site. + * + * @param \Drupal\node\NodeInterface $node + * The node containing site taxonomy references. + * + * @return \Drupal\taxonomy\TermInterface[] + * An array of ordered taxonomy terms from shallowest to deepest. + */ + protected function getOrderedSectionTerms(NodeInterface $node) { + if (!$node->hasField('field_node_primary_site') || $node->get('field_node_primary_site')->isEmpty()) { + return []; + } + + $primary_id = $node->get('field_node_primary_site')->target_id; + $field_items = $node->get('field_node_site'); + $direct_terms = ($field_items instanceof EntityReferenceFieldItemListInterface) ? $field_items->referencedEntities() : []; + + $term_storage = $this->entityTypeManager->getStorage('taxonomy_term'); + $all_relevant_terms = []; + + foreach ($direct_terms as $term) { + // If the section term is the same as the primary site, skip it. + // Prevents builder from treating Primary Site as its own Section. + if ($term->id() == $primary_id) { + continue; + } + + // Load all ancestors. + $ancestors = $term_storage->loadAllParents($term->id()); + foreach ($ancestors as $ancestor) { + // Exclude Level 1 (Primary Site) but keep everything else (Level 2+). + if ($ancestor->id() != $primary_id) { + $all_relevant_terms[$ancestor->id()] = $ancestor; + } + } + } + + // Sort terms by depth so Parent comes before Grandchild. + usort($all_relevant_terms, function ($a, $b) use ($term_storage) { + $depth_a = count($term_storage->loadAllParents($a->id())); + $depth_b = count($term_storage->loadAllParents($b->id())); + return $depth_a <=> $depth_b; + }); + + return $all_relevant_terms; + } + + /** + * Finds the root of a menu by weight, using title resolution logic. + * + * @param string $menu_name + * The machine name of the menu. + * @param string|null $primary_menu_id + * The machine name of the primary menu for title logic. + * + * @return array|null + * The root crumb array or NULL if not found. + */ + protected function getMenuRootByWeight($menu_name, $primary_menu_id = NULL) { + $parameters = new MenuTreeParameters(); + $parameters->onlyEnabledLinks(); + $tree = $this->menuTree->load($menu_name, $parameters); + + $root_element = NULL; + $min_weight = NULL; + + foreach ($tree as $element) { + $weight = $element->link->getWeight(); + if ($min_weight === NULL || $weight < $min_weight) { + $min_weight = $weight; + $root_element = $element; + } + } + + if ($root_element) { + $title = $this->resolveLinkTitle($root_element->link, $menu_name, $primary_menu_id); + return [ + 'title' => $title, + 'url' => $root_element->link->getUrlObject()->toString(), + ]; + } + return NULL; + } + + /** + * Generates a trail from a specific menu for a given node. + * + * @param string $menu_name + * The machine name of the menu to search. + * @param string $targetNid + * The node ID to search for. + * @param string|null $primary_menu_id + * The machine name of the primary menu. + * @param bool $skip_root + * Whether to skip prepending the menu root/first item. + * + * @return array|null + * The trail array or NULL if the node is not in the menu. + */ + protected function getTrailFromMenu($menu_name, $targetNid, $primary_menu_id = NULL, $skip_root = FALSE) { + $parameters = new MenuTreeParameters(); + $tree = $this->menuTree->load($menu_name, $parameters); + if (empty($tree)) { + return NULL; + } + + $trail = $this->searchTree($tree, $targetNid, 'nid', [], $menu_name, $primary_menu_id); + + if ($trail && !$skip_root) { + $root_crumb = $this->getMenuRootByWeight($menu_name, $primary_menu_id); + if ($root_crumb && $trail[0]['url'] !== $root_crumb['url']) { + array_unshift($trail, $root_crumb); + } + } + return $trail; + } + + /** + * Recursively searches a menu tree for a target NID or URL. + * + * @param array $tree + * The menu tree array. + * @param string $target + * The NID or URL to search for. + * @param string $mode + * Either 'nid' or 'url'. + * @param array $trail + * The accumulated trail. + * @param string|null $current_menu_id + * The ID of the menu currently being searched. + * @param string|null $primary_menu_id + * The ID of the primary site menu. + * + * @return array|null + * The found trail or NULL. + */ + protected function searchTree(array $tree, $target, $mode = 'nid', $trail = [], $current_menu_id = NULL, $primary_menu_id = NULL) { + foreach ($tree as $element) { + $link = $element->link; + if (!$link->isEnabled()) { + continue; + } + + try { + $currentUrl = $link->getUrlObject()->toString(); + $title = $this->resolveLinkTitle($link, $current_menu_id, $primary_menu_id); + } + catch (\Exception $e) { + continue; + } + + $currentTrail = $trail; + $currentTrail[] = ['title' => $title, 'url' => $currentUrl]; + + $matched = FALSE; + if ($mode === 'nid') { + $urlObj = $link->getUrlObject(); + if ($urlObj->isRouted() && $urlObj->getRouteName() === 'entity.node.canonical') { + $params = $urlObj->getRouteParameters(); + if (isset($params['node']) && (string) $params['node'] === (string) $target) { + $matched = TRUE; + } + } + } + elseif ($mode === 'url') { + $normTarget = rtrim(parse_url($target, PHP_URL_PATH), '/'); + $normCurrent = rtrim(parse_url($currentUrl, PHP_URL_PATH), '/'); + if ($normTarget === $normCurrent && !empty($normTarget)) { + $matched = TRUE; + } + } + + if ($matched) { + return $currentTrail; + } + + if (!empty($element->subtree)) { + if ($result = $this->searchTree($element->subtree, $target, $mode, $currentTrail, $current_menu_id, $primary_menu_id)) { + return $result; + } + } + } + return NULL; + } + + /** + * Ensures Primary Menu items use Menu Title; Section items use Node Title. + * + * @param \Drupal\Core\Menu\MenuLinkInterface $link + * The menu link plugin. + * @param string|null $current_menu_id + * The menu being searched. + * @param string|null $primary_menu_id + * The primary site menu ID. + * + * @return string + * The resolved title. + */ + protected function resolveLinkTitle($link, $current_menu_id, $primary_menu_id) { + if ($current_menu_id === $primary_menu_id) { + return $link->getTitle(); + } + + $urlObj = $link->getUrlObject(); + if ($urlObj->isRouted() && $urlObj->getRouteName() === 'entity.node.canonical') { + $params = $urlObj->getRouteParameters(); + if (!empty($params['node'])) { + $node = $this->entityTypeManager->getStorage('node')->load($params['node']); + if ($node) { + return $node->getTitle(); + } + } + } + return $link->getTitle(); + } + + /** + * Gets a trail based on a specific URL within a menu. + * + * @param string $menu_name + * The menu machine name. + * @param string $url + * The URL to search for. + * @param string|null $primary_menu_id + * The primary menu machine name. + * + * @return array|null + * The trail or NULL. + */ + protected function getTrailByUrl($menu_name, $url, $primary_menu_id = NULL) { + $parameters = new MenuTreeParameters(); + $tree = $this->menuTree->load($menu_name, $parameters); + return $this->searchTree($tree, $url, 'url', [], $menu_name, $primary_menu_id); + } + + /** + * Generates the starting crumb for the primary site home. + * + * @param \Drupal\taxonomy\TermInterface $site_term + * The primary site taxonomy term. + * + * @return array + * The home crumb array with the title forced to 'Home'. + */ + protected function getPrimaryHomeLink(TermInterface $site_term) { + $url = '/'; + if (!$site_term->get('field_site_homepage')->isEmpty()) { + $home_node = $site_term->get('field_site_homepage')->entity; + if ($home_node instanceof NodeInterface && !$home_node->isNew()) { + $url = $home_node->toUrl()->toString(); + } + } + + return ['title' => 'Home', 'url' => $url]; + } + + /** + * Removes duplicate items from the trail based on the URL path. + * + * @param array $trail + * The raw trail array. + * + * @return array + * The deduplicated trail. + */ + protected function deduplicateTrail(array $trail) { + $unique = []; + $seen = []; + foreach ($trail as $item) { + $normUrl = rtrim(parse_url($item['url'], PHP_URL_PATH), '/'); + if (!isset($seen[$normUrl])) { + $unique[] = $item; + $seen[$normUrl] = TRUE; + } + } + return $unique; + } + +} diff --git a/modules/tide_breadcrumbs/tide_breadcrumbs.info.yml b/modules/tide_breadcrumbs/tide_breadcrumbs.info.yml new file mode 100755 index 00000000..9e220f3b --- /dev/null +++ b/modules/tide_breadcrumbs/tide_breadcrumbs.info.yml @@ -0,0 +1,8 @@ +name: 'Tide breadcrumbs' +type: module +description: 'Provides a chained breadcrumb system that bridges Primary and Section site menus based on taxonomy hierarchy.' +package: Tide +core_version_requirement: ^10 || ^11 +dependencies: + - dpc-sdp:tide_core + - dpc-sdp:tide_api diff --git a/modules/tide_breadcrumbs/tide_breadcrumbs.libraries.yml b/modules/tide_breadcrumbs/tide_breadcrumbs.libraries.yml new file mode 100644 index 00000000..fa189496 --- /dev/null +++ b/modules/tide_breadcrumbs/tide_breadcrumbs.libraries.yml @@ -0,0 +1,5 @@ +breadcrumb_styles: + version: 1.x + css: + theme: + css/breadcrumb.css: {} \ No newline at end of file diff --git a/modules/tide_breadcrumbs/tide_breadcrumbs.module b/modules/tide_breadcrumbs/tide_breadcrumbs.module new file mode 100644 index 00000000..e071ae9d --- /dev/null +++ b/modules/tide_breadcrumbs/tide_breadcrumbs.module @@ -0,0 +1,104 @@ +id() === 'node') { + $fields['tide_breadcrumb'] = BaseFieldDefinition::create('map') + ->setLabel(t('Tide Breadcrumb')) + ->setComputed(TRUE) + ->setClass('\Drupal\tide_breadcrumbs\BreadcrumbComputedField') + ->setCardinality(FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED) + ->setReadOnly(TRUE); + } + + return $fields; +} + +/** + * Implements hook_entity_extra_field_info(). + * + * Defines a pseudo-field (extra field) for the 'display' context of all node + * bundles. This allows the computed breadcrumb trail to be toggled and + * positioned via the "Manage Display" UI. + */ +function tide_breadcrumbs_entity_extra_field_info() { + $extra = []; + // Add this to specific node types, or all of them. + foreach (NodeType::loadMultiple() as $bundle) { + $extra['node'][$bundle->id()]['display']['computed_breadcrumb_trail'] = [ + 'label' => t('Computed Breadcrumb Trail'), + 'description' => t('Displays the full chained breadcrumb trail.'), + 'weight' => -10, + 'visible' => TRUE, + ]; + } + return $extra; +} + +/** + * Implements hook_ENTITY_TYPE_view(). + * + * Responsible for rendering the visual representation of the breadcrumb trail + * when a node is viewed. It consumes the TideBreadcrumbBuilder service to + * generate the trail and formats it as a series of links separated by arrows. + */ +function tide_breadcrumbs_node_view(array &$build, NodeInterface $node, EntityViewDisplayInterface $display, $view_mode) { + // Check if our custom extra field is enabled for this view mode. + if ($display->getComponent('computed_breadcrumb_trail')) { + /** @var \Drupal\tide_breadcrumbs\TideBreadcrumbBuilder $builder */ + $builder = \Drupal::service('tide_breadcrumbs.breadcrumb_builder'); + $trail = $builder->buildFullTrail($node); + + if (!empty($trail)) { + $breadcrumb_items = []; + foreach ($trail as $item) { + $breadcrumb_items[] = [ + '#type' => 'link', + '#title' => $item['title'], + '#url' => Url::fromUserInput($item['url']), + ]; + } + + // Build the render array with a heading and horizontal layout. + $build['computed_breadcrumb_trail'] = [ + '#prefix' => '
Breadcrumb:
', + '#attached' => [ + 'library' => ['tide_breadcrumbs/breadcrumb_styles'], + ], + ]; + + // Insert separators between links. + foreach ($breadcrumb_items as $index => $link_render) { + $build['computed_breadcrumb_trail'][] = $link_render; + + // Don't add an arrow after the very last item. + if ($index < count($breadcrumb_items) - 1) { + $build['computed_breadcrumb_trail'][] = ['#markup' => ' ']; + } + } + } + } +} diff --git a/modules/tide_breadcrumbs/tide_breadcrumbs.services.yml b/modules/tide_breadcrumbs/tide_breadcrumbs.services.yml new file mode 100644 index 00000000..3f874092 --- /dev/null +++ b/modules/tide_breadcrumbs/tide_breadcrumbs.services.yml @@ -0,0 +1,4 @@ +services: + tide_breadcrumbs.breadcrumb_builder: + class: Drupal\tide_breadcrumbs\TideBreadcrumbBuilder + arguments: ['@menu.link_tree', '@entity_type.manager']