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..88a79a7d --- /dev/null +++ b/modules/tide_breadcrumbs/src/BreadcrumbComputedField.php @@ -0,0 +1,61 @@ +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); + $this->list = []; + + if (!empty($trail) && is_array($trail)) { + 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..da5974f7 --- /dev/null +++ b/modules/tide_breadcrumbs/src/TideBreadcrumbBuilder.php @@ -0,0 +1,598 @@ +menuTree = $menu_tree; + $this->entityTypeManager = $entity_type_manager; + $this->database = $database; + } + + /** + * 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) { + // Check static cache first. Using NID as key. + $nid = $node->id() ?: 'new'; + if (isset($this->staticTrail[$nid])) { + return $this->staticTrail[$nid]; + } + + // Initialize tags with the target node. + $this->discoveredTags = $node->getCacheTags(); + + $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); + } + + $this->staticTrail['new'] = [$home_crumb]; + return $this->staticTrail['new']; + } + + $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); + } + } + + $this->staticTrail[$targetNid] = $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) { + $nid = $node->id() ?: 'new'; + if (isset($this->staticSectionTerms[$nid])) { + return $this->staticSectionTerms[$nid]; + } + + 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; + } + + // Add the term itself to discovery. + $this->discoveredTags = array_merge($this->discoveredTags, $term->getCacheTags()); + + // 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; + // Add ancestor terms to discovery. + $this->discoveredTags = array_merge($this->discoveredTags, $ancestor->getCacheTags()); + } + } + } + + // 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; + }); + + $this->staticSectionTerms[$nid] = $all_relevant_terms; + 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['title'] ?? $root_element->link->getTitle(), + '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_data = $this->resolveLinkTitle($link, $current_menu_id, $primary_menu_id); + $title = $title_data['title']; + } + 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): array { + // Primary menu items must use the menu link title. + if ($current_menu_id === $primary_menu_id) { + return [ + 'title' => $link->getTitle(), + 'attributes' => [], + ]; + } + + // Section items should try to use the node title. + $urlObj = $link->getUrlObject(); + if ($urlObj->isRouted() && $urlObj->getRouteName() === 'entity.node.canonical') { + $params = $urlObj->getRouteParameters(); + $nid = $params['node'] ?? NULL; + + if ($nid) { + /** @var \Drupal\Core\Language\LanguageManagerInterface $language_manager */ + $lang_code = \Drupal::languageManager()->getCurrentLanguage()->getId(); + + // Use direct db query to avoid triggering hook_node_storage_load(). + // This prevents the infinite loop. + $query = $this->database->select('node_field_data', 'nfd'); + $query->fields('nfd', ['title']); + $query->condition('nfd.nid', $nid); + $query->condition('nfd.langcode', $lang_code); + $node_title = $query->execute()->fetchField(); + + // Fallback to default translation. + if (!$node_title) { + $node_title = $this->database->select('node_field_data', 'nfd') + ->fields('nfd', ['title']) + ->condition('nfd.nid', $nid) + ->condition('nfd.default_langcode', 1) + ->execute()->fetchField(); + } + + if ($node_title) { + $this->discoveredTags[] = "node:$nid"; + + return [ + 'title' => $node_title, + 'attributes' => ['data-breadcrumb-source' => 'node'], + ]; + } + } + } + + // Fallback to Menu Link Title. + return [ + 'title' => $link->getTitle(), + 'attributes' => [], + ]; + } + + /** + * 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) { + // Add taxonomy term tags to discovery. + $this->discoveredTags = array_merge($this->discoveredTags, $site_term->getCacheTags()); + + $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(); + // Add home node tags to discovery. + $this->discoveredTags = array_merge($this->discoveredTags, $home_node->getCacheTags()); + } + } + + 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; + } + + /** + * Returns cache tags for automatic invalidation. + * + * @param \Drupal\node\NodeInterface $node + * The current node. + * + * @return array + * An array of unique cache tags. + */ + public function getCacheTags(NodeInterface $node) { + $nid = $node->id() ?: 'new'; + + // Ensure the trail has been built to discover tags. + if (!isset($this->staticTrail[$nid])) { + $this->buildFullTrail($node); + } + + $tags = $this->discoveredTags; + + // Primary Site Menu Dependency. + if ($node->hasField('field_node_primary_site') && !$node->get('field_node_primary_site')->isEmpty()) { + $site = $node->get('field_node_primary_site')->entity; + if ($site instanceof TermInterface) { + $tags = array_merge($tags, $site->getCacheTags()); + + if ($site->hasField('field_site_main_menu') && !$site->get('field_site_main_menu')->isEmpty()) { + $tags[] = 'config:system.menu.' . $site->get('field_site_main_menu')->target_id; + } + } + } + + // Section Site Menu Dependencies. + $section_terms = $this->getOrderedSectionTerms($node); + foreach ($section_terms as $term) { + if ($term->hasField('field_site_main_menu') && !$term->get('field_site_main_menu')->isEmpty()) { + $tags[] = 'config:system.menu.' . $term->get('field_site_main_menu')->target_id; + } + } + + return array_unique($tags); + } + + /** + * Returns cache contexts for language and path awareness. + * + * @return array + * An array of cache contexts. + */ + public function getCacheContexts() { + return ['url.path', 'languages', 'user.permissions']; + } + +} 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..09d25856 --- /dev/null +++ b/modules/tide_breadcrumbs/tide_breadcrumbs.module @@ -0,0 +1,159 @@ +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' => ' ']; + } + } + } + } +} + +/** + * Implements hook_entity_storage_load(). + * + * This hook is used to inject breadcrumb cache metadata (tags and contexts) + * directly into node entities as they are loaded from storage. + * + * This is critical for: + * - JSON:API: Ensures parent node tags appear in + * X-Drupal-Cache-Tags headers. + * + * PERFORMANCE: + * - Includes a CLI guard to prevent heavy breadcrumb calculation during + * bulk Drush operations or Cron jobs unless necessary. + * + * @see \Drupal\tide_breadcrumbs\TideBreadcrumbBuilder + * @see \Drupal\tide_breadcrumbs\BreadcrumbComputedField + */ +function tide_breadcrumbs_node_storage_load(array $entities) { + if (PHP_SAPI === 'cli') { + return; + } + + $is_processing = &drupal_static(__FUNCTION__ . '_running', FALSE); + if ($is_processing) { + return; + } + + $is_processing = TRUE; + + try { + $service = \Drupal::service('tide_breadcrumbs.breadcrumb_builder'); + $processed = &drupal_static(__FUNCTION__, []); + + foreach ($entities as $node) { + if ($node instanceof NodeInterface) { + $nid = $node->id(); + if (!isset($processed[$nid])) { + $service->buildFullTrail($node); + $node->addCacheTags($service->getCacheTags($node)); + $node->addCacheContexts($service->getCacheContexts()); + $processed[$nid] = TRUE; + } + } + } + } + catch (\Exception $e) { + \Drupal::logger('tide_breadcrumbs')->error($e->getMessage()); + } + finally { + // This ensures the lock is ALWAYS released. + // Even if an error occurs. + $is_processing = FALSE; + } +} diff --git a/modules/tide_breadcrumbs/tide_breadcrumbs.services.yml b/modules/tide_breadcrumbs/tide_breadcrumbs.services.yml new file mode 100644 index 00000000..965d4a02 --- /dev/null +++ b/modules/tide_breadcrumbs/tide_breadcrumbs.services.yml @@ -0,0 +1,7 @@ +services: + tide_breadcrumbs.breadcrumb_builder: + class: Drupal\tide_breadcrumbs\TideBreadcrumbBuilder + arguments: + - '@menu.link_tree' + - '@entity_type.manager' + - '@database'