From 8fedbc6284636fc95bb510aa918ef1a956615931 Mon Sep 17 00:00:00 2001 From: Md Nadim Hossain Date: Wed, 25 Feb 2026 15:15:29 +1100 Subject: [PATCH 1/7] [SD-1440] Added tide_breadcrumb module. --- modules/tide_breadcrumbs/css/breadcrumb.css | 34 ++ .../src/BreadcrumbComputedField.php | 62 +++ .../src/TideBreadcrumbBuilder.php | 408 ++++++++++++++++++ .../tide_breadcrumbs.info.yml | 8 + .../tide_breadcrumbs.libraries.yml | 5 + .../tide_breadcrumbs/tide_breadcrumbs.module | 97 +++++ .../tide_breadcrumbs.services.yml | 4 + 7 files changed, 618 insertions(+) create mode 100644 modules/tide_breadcrumbs/css/breadcrumb.css create mode 100644 modules/tide_breadcrumbs/src/BreadcrumbComputedField.php create mode 100644 modules/tide_breadcrumbs/src/TideBreadcrumbBuilder.php create mode 100755 modules/tide_breadcrumbs/tide_breadcrumbs.info.yml create mode 100644 modules/tide_breadcrumbs/tide_breadcrumbs.libraries.yml create mode 100644 modules/tide_breadcrumbs/tide_breadcrumbs.module create mode 100644 modules/tide_breadcrumbs/tide_breadcrumbs.services.yml 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..e2945b45 --- /dev/null +++ b/modules/tide_breadcrumbs/src/BreadcrumbComputedField.php @@ -0,0 +1,62 @@ +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.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..a4ea6494 --- /dev/null +++ b/modules/tide_breadcrumbs/src/TideBreadcrumbBuilder.php @@ -0,0 +1,408 @@ +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) { + $targetNid = (string) $node->id(); + $nodeTitle = $node->getTitle(); + + // 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')->isEmpty() + ? $primary_site_term->get('field_site_main_menu')->entity->id() : NULL; + + // Start with Absolute Primary Home. + $chained_trail[] = $this->getPrimaryHomeLink($primary_site_term); + + // THE RELAY: Chain from Parent -> Child -> Grandchild. + foreach ($section_terms as $term) { + if ($term->get('field_site_main_menu')->isEmpty()) { + continue; + } + + $menu_id = $term->get('field_site_main_menu')->entity->id(); + + // 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) { + $primary_search = $this->getTrailFromMenu($primary_menu_id, $targetNid, $primary_menu_id); + 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); + if (!empty($chained_trail)) { + $chained_trail[count($chained_trail) - 1]['title'] = $nodeTitle; + } + + 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 ($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. + * + * @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) { + $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) { + $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) { + $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..60127477 --- /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 \ No newline at end of file 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..0156f67e --- /dev/null +++ b/modules/tide_breadcrumbs/tide_breadcrumbs.module @@ -0,0 +1,97 @@ +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 (\Drupal\node\Entity\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.builder'); + $trail = $builder->buildFullTrail($node); + + if (!empty($trail)) { + $breadcrumb_items = []; + foreach ($trail as $item) { + $breadcrumb_items[] = [ + '#type' => 'link', + '#title' => $item['title'], + '#url' => \Drupal\Core\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..fafcaff1 --- /dev/null +++ b/modules/tide_breadcrumbs/tide_breadcrumbs.services.yml @@ -0,0 +1,4 @@ +services: + tide_breadcrumbs.builder: + class: Drupal\tide_breadcrumbs\TideBreadcrumbBuilder + arguments: ['@menu.link_tree', '@entity_type.manager'] From 53f0e019ae03dce2ff6d07cd215bad9eedeaa823 Mon Sep 17 00:00:00 2001 From: Md Nadim Hossain Date: Wed, 25 Feb 2026 16:49:42 +1100 Subject: [PATCH 2/7] [SD-1440] Updated service name and lint fix. --- .../src/BreadcrumbComputedField.php | 4 +- .../src/TideBreadcrumbBuilder.php | 127 ++++++++++-------- .../tide_breadcrumbs.info.yml | 2 +- .../tide_breadcrumbs/tide_breadcrumbs.module | 23 ++-- .../tide_breadcrumbs.services.yml | 2 +- 5 files changed, 89 insertions(+), 69 deletions(-) diff --git a/modules/tide_breadcrumbs/src/BreadcrumbComputedField.php b/modules/tide_breadcrumbs/src/BreadcrumbComputedField.php index e2945b45..96d9e7a9 100644 --- a/modules/tide_breadcrumbs/src/BreadcrumbComputedField.php +++ b/modules/tide_breadcrumbs/src/BreadcrumbComputedField.php @@ -19,7 +19,7 @@ class BreadcrumbComputedField extends FieldItemList { /** * Computes the breadcrumb trail value. * - * Fetches the trail from the tide_breadcrumbs.builder service and populates + * Fetches the trail from the tide_breadcrumbs.breadcrumb_builder service and populates * the field items with 'title' and 'url' properties for each crumb. */ protected function computeValue() { @@ -30,7 +30,7 @@ protected function computeValue() { } /** @var \Drupal\tide_breadcrumbs\TideBreadcrumbBuilder $breadcrumb_service */ - $breadcrumb_service = \Drupal::service('tide_breadcrumbs.builder'); + $breadcrumb_service = \Drupal::service('tide_breadcrumbs.breadcrumb_builder'); $trail = $breadcrumb_service->buildFullTrail($node); if (!empty($trail) && is_array($trail)) { diff --git a/modules/tide_breadcrumbs/src/TideBreadcrumbBuilder.php b/modules/tide_breadcrumbs/src/TideBreadcrumbBuilder.php index a4ea6494..de2da796 100644 --- a/modules/tide_breadcrumbs/src/TideBreadcrumbBuilder.php +++ b/modules/tide_breadcrumbs/src/TideBreadcrumbBuilder.php @@ -2,11 +2,11 @@ namespace Drupal\tide_breadcrumbs; -use Drupal\node\NodeInterface; -use Drupal\Core\Menu\MenuLinkTreeInterface; -use Drupal\Core\Menu\MenuTreeParameters; use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Field\EntityReferenceFieldItemListInterface; +use Drupal\Core\Menu\MenuLinkTreeInterface; +use Drupal\Core\Menu\MenuTreeParameters; +use Drupal\node\NodeInterface; use Drupal\taxonomy\TermInterface; /** @@ -36,9 +36,9 @@ class TideBreadcrumbBuilder { * Constructs a new TideBreadcrumbBuilder object. * * @param \Drupal\Core\Menu\MenuLinkTreeInterface $menu_tree - * The menu link tree service. + * The menu link tree service. * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager - * The entity type manager. + * The entity type manager. */ public function __construct( MenuLinkTreeInterface $menu_tree, @@ -56,15 +56,15 @@ public function __construct( * within its specific menu. * * @param \Drupal\node\NodeInterface $node - * The node for which to build the trail. + * The node for which to build the trail. * * @return array - * An array of breadcrumb items, each containing 'title' and 'url'. + * An array of breadcrumb items, each containing 'title' and 'url'. */ public function buildFullTrail(NodeInterface $node) { $targetNid = (string) $node->id(); $nodeTitle = $node->getTitle(); - + // 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; @@ -73,9 +73,9 @@ public function buildFullTrail(NodeInterface $node) { $found_node_in_menu = FALSE; if ($primary_site_term instanceof TermInterface) { - $primary_menu_id = !$primary_site_term->get('field_site_main_menu')->isEmpty() + $primary_menu_id = !$primary_site_term->get('field_site_main_menu')->isEmpty() ? $primary_site_term->get('field_site_main_menu')->entity->id() : NULL; - + // Start with Absolute Primary Home. $chained_trail[] = $this->getPrimaryHomeLink($primary_site_term); @@ -84,9 +84,9 @@ public function buildFullTrail(NodeInterface $node) { if ($term->get('field_site_main_menu')->isEmpty()) { continue; } - + $menu_id = $term->get('field_site_main_menu')->entity->id(); - + // Search current section menu for the node. $node_trail_in_this_menu = $this->getTrailFromMenu($menu_id, $targetNid, $primary_menu_id); @@ -98,11 +98,11 @@ public function buildFullTrail(NodeInterface $node) { $chained_trail = array_merge($chained_trail, $bridge); } } - + $chained_trail = array_merge($chained_trail, $node_trail_in_this_menu); $found_node_in_menu = TRUE; - break; - } + break; + } else { // Node not here, add Section Home and continue relay. $section_root = $this->getMenuRootByWeight($menu_id, $primary_menu_id); @@ -127,7 +127,7 @@ public function buildFullTrail(NodeInterface $node) { $found_node_in_menu = TRUE; } } - + if (!$found_node_in_menu) { $chained_trail[] = ['title' => $nodeTitle, 'url' => $node->toUrl()->toString()]; } @@ -147,10 +147,10 @@ public function buildFullTrail(NodeInterface $node) { * Crawls taxonomy to find all parents between tagged term and Primary Site. * * @param \Drupal\node\NodeInterface $node - * The node containing site taxonomy references. + * The node containing site taxonomy references. * * @return \Drupal\taxonomy\TermInterface[] - * An array of ordered taxonomy terms from shallowest to deepest. + * 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()) { @@ -160,12 +160,14 @@ protected function getOrderedSectionTerms(NodeInterface $node) { $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 ($term->id() == $primary_id) continue; + if ($term->id() == $primary_id) { + continue; + } // Load all ancestors. $ancestors = $term_storage->loadAllParents($term->id()); @@ -178,7 +180,7 @@ protected function getOrderedSectionTerms(NodeInterface $node) { } // Sort terms by depth so Parent comes before Grandchild. - usort($all_relevant_terms, function($a, $b) use ($term_storage) { + 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; @@ -191,18 +193,18 @@ protected function getOrderedSectionTerms(NodeInterface $node) { * Finds the root of a menu by weight, using title resolution logic. * * @param string $menu_name - * The machine name of the menu. + * The machine name of the menu. * @param string|null $primary_menu_id - * The machine name of the primary menu for title logic. + * The machine name of the primary menu for title logic. * * @return array|null - * The root crumb array or NULL if not found. + * 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; @@ -228,19 +230,21 @@ protected function getMenuRootByWeight($menu_name, $primary_menu_id = NULL) { * Generates a trail from a specific menu for a given node. * * @param string $menu_name - * The machine name of the menu to search. + * The machine name of the menu to search. * @param string $targetNid - * The node ID to search for. + * The node ID to search for. * @param string|null $primary_menu_id - * The machine name of the primary menu. + * The machine name of the primary menu. * * @return array|null - * The trail array or NULL if the node is not in the menu. + * The trail array or NULL if the node is not in the menu. */ protected function getTrailFromMenu($menu_name, $targetNid, $primary_menu_id = NULL) { $parameters = new MenuTreeParameters(); $tree = $this->menuTree->load($menu_name, $parameters); - if (empty($tree)) return NULL; + if (empty($tree)) { + return NULL; + } $trail = $this->searchTree($tree, $targetNid, 'nid', [], $menu_name, $primary_menu_id); @@ -257,52 +261,60 @@ protected function getTrailFromMenu($menu_name, $targetNid, $primary_menu_id = N * Recursively searches a menu tree for a target NID or URL. * * @param array $tree - * The menu tree array. + * The menu tree array. * @param string $target - * The NID or URL to search for. + * The NID or URL to search for. * @param string $mode - * Either 'nid' or 'url'. + * Either 'nid' or 'url'. * @param array $trail - * The accumulated trail. + * The accumulated trail. * @param string|null $current_menu_id - * The ID of the menu currently being searched. + * The ID of the menu currently being searched. * @param string|null $primary_menu_id - * The ID of the primary site menu. + * The ID of the primary site menu. * * @return array|null - * The found trail or 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; + if (!$link->isEnabled()) { + continue; + } try { $currentUrl = $link->getUrlObject()->toString(); $title = $this->resolveLinkTitle($link, $current_menu_id, $primary_menu_id); - } catch (\Exception $e) { continue; } + } + catch (\Exception $e) { + continue; + } $currentTrail = $trail; $currentTrail[] = ['title' => $title, 'url' => $currentUrl]; - $matched = false; + $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; + if (isset($params['node']) && (string) $params['node'] === (string) $target) { + $matched = TRUE; } } - } elseif ($mode === 'url') { + } + 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; + $matched = TRUE; } } - if ($matched) return $currentTrail; + if ($matched) { + return $currentTrail; + } if (!empty($element->subtree)) { if ($result = $this->searchTree($element->subtree, $target, $mode, $currentTrail, $current_menu_id, $primary_menu_id)) { @@ -317,14 +329,14 @@ protected function searchTree(array $tree, $target, $mode = 'nid', $trail = [], * Ensures Primary Menu items use Menu Title; Section items use Node Title. * * @param \Drupal\Core\Menu\MenuLinkInterface $link - * The menu link plugin. + * The menu link plugin. * @param string|null $current_menu_id - * The menu being searched. + * The menu being searched. * @param string|null $primary_menu_id - * The primary site menu ID. + * The primary site menu ID. * * @return string - * The resolved title. + * The resolved title. */ protected function resolveLinkTitle($link, $current_menu_id, $primary_menu_id) { if ($current_menu_id === $primary_menu_id) { @@ -348,14 +360,14 @@ protected function resolveLinkTitle($link, $current_menu_id, $primary_menu_id) { * Gets a trail based on a specific URL within a menu. * * @param string $menu_name - * The menu machine name. + * The menu machine name. * @param string $url - * The URL to search for. + * The URL to search for. * @param string|null $primary_menu_id - * The primary menu machine name. + * The primary menu machine name. * * @return array|null - * The trail or NULL. + * The trail or NULL. */ protected function getTrailByUrl($menu_name, $url, $primary_menu_id = NULL) { $parameters = new MenuTreeParameters(); @@ -367,10 +379,10 @@ protected function getTrailByUrl($menu_name, $url, $primary_menu_id = NULL) { * Generates the starting crumb for the primary site home. * * @param \Drupal\taxonomy\TermInterface $site_term - * The primary site taxonomy term. + * The primary site taxonomy term. * * @return array - * The home crumb array with the title forced to 'Home'. + * The home crumb array with the title forced to 'Home'. */ protected function getPrimaryHomeLink(TermInterface $site_term) { $url = '/'; @@ -388,10 +400,10 @@ protected function getPrimaryHomeLink(TermInterface $site_term) { * Removes duplicate items from the trail based on the URL path. * * @param array $trail - * The raw trail array. + * The raw trail array. * * @return array - * The deduplicated trail. + * The deduplicated trail. */ protected function deduplicateTrail(array $trail) { $unique = []; @@ -405,4 +417,5 @@ protected function deduplicateTrail(array $trail) { } return $unique; } + } diff --git a/modules/tide_breadcrumbs/tide_breadcrumbs.info.yml b/modules/tide_breadcrumbs/tide_breadcrumbs.info.yml index 60127477..9e220f3b 100755 --- a/modules/tide_breadcrumbs/tide_breadcrumbs.info.yml +++ b/modules/tide_breadcrumbs/tide_breadcrumbs.info.yml @@ -5,4 +5,4 @@ package: Tide core_version_requirement: ^10 || ^11 dependencies: - dpc-sdp:tide_core - - dpc-sdp:tide_api \ No newline at end of file + - dpc-sdp:tide_api diff --git a/modules/tide_breadcrumbs/tide_breadcrumbs.module b/modules/tide_breadcrumbs/tide_breadcrumbs.module index 0156f67e..e071ae9d 100644 --- a/modules/tide_breadcrumbs/tide_breadcrumbs.module +++ b/modules/tide_breadcrumbs/tide_breadcrumbs.module @@ -1,9 +1,16 @@ id()]['display']['computed_breadcrumb_trail'] = [ 'label' => t('Computed Breadcrumb Trail'), 'description' => t('Displays the full chained breadcrumb trail.'), @@ -54,14 +61,14 @@ function tide_breadcrumbs_entity_extra_field_info() { * 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 + * 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.builder'); + $builder = \Drupal::service('tide_breadcrumbs.breadcrumb_builder'); $trail = $builder->buildFullTrail($node); if (!empty($trail)) { @@ -70,7 +77,7 @@ function tide_breadcrumbs_node_view(array &$build, NodeInterface $node, EntityVi $breadcrumb_items[] = [ '#type' => 'link', '#title' => $item['title'], - '#url' => \Drupal\Core\Url::fromUserInput($item['url']), + '#url' => Url::fromUserInput($item['url']), ]; } @@ -86,7 +93,7 @@ function tide_breadcrumbs_node_view(array &$build, NodeInterface $node, EntityVi // 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 index fafcaff1..3f874092 100644 --- a/modules/tide_breadcrumbs/tide_breadcrumbs.services.yml +++ b/modules/tide_breadcrumbs/tide_breadcrumbs.services.yml @@ -1,4 +1,4 @@ services: - tide_breadcrumbs.builder: + tide_breadcrumbs.breadcrumb_builder: class: Drupal\tide_breadcrumbs\TideBreadcrumbBuilder arguments: ['@menu.link_tree', '@entity_type.manager'] From 3ddc6b45a32bee1448e2c235d461bc526cac1006 Mon Sep 17 00:00:00 2001 From: Md Nadim Hossain Date: Thu, 26 Feb 2026 09:43:39 +1100 Subject: [PATCH 3/7] lint fix. --- modules/tide_breadcrumbs/src/BreadcrumbComputedField.php | 5 +++-- modules/tide_breadcrumbs/src/TideBreadcrumbBuilder.php | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/modules/tide_breadcrumbs/src/BreadcrumbComputedField.php b/modules/tide_breadcrumbs/src/BreadcrumbComputedField.php index 96d9e7a9..9805c65f 100644 --- a/modules/tide_breadcrumbs/src/BreadcrumbComputedField.php +++ b/modules/tide_breadcrumbs/src/BreadcrumbComputedField.php @@ -19,8 +19,9 @@ class BreadcrumbComputedField extends FieldItemList { /** * Computes the breadcrumb trail value. * - * Fetches the trail from the tide_breadcrumbs.breadcrumb_builder service and populates - * the field items with 'title' and 'url' properties for each crumb. + * Fetches the trail from the tide_breadcrumbs.breadcrumb_builder service + * and populates the field items with 'title' and + * 'url' properties for each crumb. */ protected function computeValue() { $node = $this->getEntity(); diff --git a/modules/tide_breadcrumbs/src/TideBreadcrumbBuilder.php b/modules/tide_breadcrumbs/src/TideBreadcrumbBuilder.php index de2da796..4f09c2e5 100644 --- a/modules/tide_breadcrumbs/src/TideBreadcrumbBuilder.php +++ b/modules/tide_breadcrumbs/src/TideBreadcrumbBuilder.php @@ -127,7 +127,7 @@ public function buildFullTrail(NodeInterface $node) { $found_node_in_menu = TRUE; } } - + if (!$found_node_in_menu) { $chained_trail[] = ['title' => $nodeTitle, 'url' => $node->toUrl()->toString()]; } From 320bdf4af8e742f03972f4875fe512835c92f165 Mon Sep 17 00:00:00 2001 From: Md Nadim Hossain Date: Fri, 27 Feb 2026 14:06:50 +1100 Subject: [PATCH 4/7] [SD-1440] Added fix for new or clone node to resolve the url creation error in the breadcrumb. --- .../src/TideBreadcrumbBuilder.php | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/modules/tide_breadcrumbs/src/TideBreadcrumbBuilder.php b/modules/tide_breadcrumbs/src/TideBreadcrumbBuilder.php index 4f09c2e5..a806c8c4 100644 --- a/modules/tide_breadcrumbs/src/TideBreadcrumbBuilder.php +++ b/modules/tide_breadcrumbs/src/TideBreadcrumbBuilder.php @@ -62,8 +62,25 @@ public function __construct( * 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, + ['title' => $nodeTitle, 'url' => '#'], + ]; + } + $targetNid = (string) $node->id(); - $nodeTitle = $node->getTitle(); // Get all relevant section terms (including ancestors up to Level 2). $section_terms = $this->getOrderedSectionTerms($node); @@ -388,7 +405,7 @@ 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) { + if ($home_node instanceof NodeInterface && !$home_node->isNew()) { $url = $home_node->toUrl()->toString(); } } From 90104148683f0ae17b65957899250a7eeb778981 Mon Sep 17 00:00:00 2001 From: Md Nadim Hossain Date: Mon, 2 Mar 2026 13:54:59 +1100 Subject: [PATCH 5/7] [SD-1440] tackle the emty menu field issue and remove current page from breadcrumb trail. --- .../src/TideBreadcrumbBuilder.php | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/modules/tide_breadcrumbs/src/TideBreadcrumbBuilder.php b/modules/tide_breadcrumbs/src/TideBreadcrumbBuilder.php index a806c8c4..d76c04b4 100644 --- a/modules/tide_breadcrumbs/src/TideBreadcrumbBuilder.php +++ b/modules/tide_breadcrumbs/src/TideBreadcrumbBuilder.php @@ -76,7 +76,6 @@ public function buildFullTrail(NodeInterface $node) { return [ $home_crumb, - ['title' => $nodeTitle, 'url' => '#'], ]; } @@ -90,20 +89,19 @@ public function buildFullTrail(NodeInterface $node) { $found_node_in_menu = FALSE; if ($primary_site_term instanceof TermInterface) { - $primary_menu_id = !$primary_site_term->get('field_site_main_menu')->isEmpty() - ? $primary_site_term->get('field_site_main_menu')->entity->id() : NULL; + $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) { - if ($term->get('field_site_main_menu')->isEmpty()) { + $menu_id = $term->get('field_site_main_menu')->target_id; + + if (!$menu_id) { continue; } - $menu_id = $term->get('field_site_main_menu')->entity->id(); - // Search current section menu for the node. $node_trail_in_this_menu = $this->getTrailFromMenu($menu_id, $targetNid, $primary_menu_id); @@ -153,8 +151,17 @@ public function buildFullTrail(NodeInterface $node) { // Deduplicate URLs. $chained_trail = $this->deduplicateTrail($chained_trail); + + // Remove the current page item from the breadcrumb trail. if (!empty($chained_trail)) { - $chained_trail[count($chained_trail) - 1]['title'] = $nodeTitle; + $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; From 4d669240aac438435a5094c37f90109781989719 Mon Sep 17 00:00:00 2001 From: Md Nadim Hossain Date: Thu, 12 Mar 2026 11:32:11 +1100 Subject: [PATCH 6/7] [SD-1440] Updated breadcrumb build logic when there is only one site section and it same as primary site. --- .../tide_breadcrumbs/src/TideBreadcrumbBuilder.php | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/modules/tide_breadcrumbs/src/TideBreadcrumbBuilder.php b/modules/tide_breadcrumbs/src/TideBreadcrumbBuilder.php index d76c04b4..a63c6e11 100644 --- a/modules/tide_breadcrumbs/src/TideBreadcrumbBuilder.php +++ b/modules/tide_breadcrumbs/src/TideBreadcrumbBuilder.php @@ -136,7 +136,9 @@ public function buildFullTrail(NodeInterface $node) { // FALLBACK: Node not found in any Section Menu. if (!$found_node_in_menu) { if (count($chained_trail) === 1 && $primary_menu_id) { - $primary_search = $this->getTrailFromMenu($primary_menu_id, $targetNid, $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; @@ -189,6 +191,8 @@ protected function getOrderedSectionTerms(NodeInterface $node) { $all_relevant_terms = []; foreach ($direct_terms as $term) { + // If the section term is the same as the primary site, skip it. + // This prevents the builder from treating the Primary Site as its own Section. if ($term->id() == $primary_id) { continue; } @@ -259,11 +263,13 @@ protected function getMenuRootByWeight($menu_name, $primary_menu_id = NULL) { * 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) { + 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)) { @@ -272,7 +278,7 @@ protected function getTrailFromMenu($menu_name, $targetNid, $primary_menu_id = N $trail = $this->searchTree($tree, $targetNid, 'nid', [], $menu_name, $primary_menu_id); - if ($trail) { + 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); From 026005db29d2ebab6991bc7ce23a1102af414442 Mon Sep 17 00:00:00 2001 From: Md Nadim Hossain Date: Thu, 12 Mar 2026 11:58:51 +1100 Subject: [PATCH 7/7] [SD-1440] lint fix. --- modules/tide_breadcrumbs/src/TideBreadcrumbBuilder.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/tide_breadcrumbs/src/TideBreadcrumbBuilder.php b/modules/tide_breadcrumbs/src/TideBreadcrumbBuilder.php index a63c6e11..183395f8 100644 --- a/modules/tide_breadcrumbs/src/TideBreadcrumbBuilder.php +++ b/modules/tide_breadcrumbs/src/TideBreadcrumbBuilder.php @@ -192,7 +192,7 @@ protected function getOrderedSectionTerms(NodeInterface $node) { foreach ($direct_terms as $term) { // If the section term is the same as the primary site, skip it. - // This prevents the builder from treating the Primary Site as its own Section. + // Prevents builder from treating Primary Site as its own Section. if ($term->id() == $primary_id) { continue; }