You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maven.apache.org by sl...@apache.org on 2026/08/24 17:39:09 UTC
(maven-doxia-sitetools) 01/01: Derive breadcrumbs from menu hierarchy when none are declared
This is an automated email from the ASF dual-hosted git repository.
slachiewicz pushed a commit to branch derive-breadcrumbs-from-menu
in repository https://gitbox.apache.org/repos/asf/maven-doxia-sitetools.git
commit cf4a13d792035a04faa258fddf1b01379bf03a7c
Author: Sylwester Lachiewicz <sl...@apache.org>
AuthorDate: Mon Aug 24 19:30:05 2026 +0200
Derive breadcrumbs from menu hierarchy when none are declared
Adds an opt-in SiteRenderingContext.deriveBreadcrumbsFromMenu flag, default off.
When set, and a site declares no explicit <breadcrumbs>, the renderer locates the
current document among the resolved <menu><item href> values and builds a trail
from the enclosing menu's name and the chain of enclosing <item> names.
The trail carries ancestors only, not the matched item: skins render the page
title after the breadcrumbs, so including it would print the page name twice.
Derivation happens per document rather than during model assembly, because only
DocumentRenderingContext knows which page is rendering. The shared SiteModel is
cloned before the trail is set, so one document's breadcrumbs cannot leak into
another's.
Menu ref, inherit and inheritAsRef resolution is assumed already done by the
caller, as maven-site-plugin's site tool does before invoking the renderer. An
unresolved ref menu has no items, so derivation finds nothing and the site
renders as it does today.
See https://github.com/apache/maven-site-plugin/issues/1300
---
.../doxia/siterenderer/DefaultSiteRenderer.java | 20 ++-
.../maven/doxia/siterenderer/MenuBreadcrumbs.java | 131 +++++++++++++++++
.../doxia/siterenderer/SiteRenderingContext.java | 25 ++++
.../DeriveBreadcrumbsFromMenuTest.java | 152 +++++++++++++++++++
.../doxia/siterenderer/MenuBreadcrumbsTest.java | 161 +++++++++++++++++++++
5 files changed, 487 insertions(+), 2 deletions(-)
diff --git a/doxia-site-renderer/src/main/java/org/apache/maven/doxia/siterenderer/DefaultSiteRenderer.java b/doxia-site-renderer/src/main/java/org/apache/maven/doxia/siterenderer/DefaultSiteRenderer.java
index 51ea130..c7bd5b9 100644
--- a/doxia-site-renderer/src/main/java/org/apache/maven/doxia/siterenderer/DefaultSiteRenderer.java
+++ b/doxia-site-renderer/src/main/java/org/apache/maven/doxia/siterenderer/DefaultSiteRenderer.java
@@ -70,6 +70,7 @@ import org.apache.maven.doxia.parser.Parser;
import org.apache.maven.doxia.parser.manager.ParserNotFoundException;
import org.apache.maven.doxia.parser.module.ParserModule;
import org.apache.maven.doxia.parser.module.ParserModuleManager;
+import org.apache.maven.doxia.site.LinkItem;
import org.apache.maven.doxia.site.SiteModel;
import org.apache.maven.doxia.site.skin.ResourceCondition;
import org.apache.maven.doxia.site.skin.SkinModel;
@@ -609,9 +610,24 @@ public class DefaultSiteRenderer implements Renderer {
}
}
}
- context.put("site", siteRenderingContext.getSiteModel());
+ SiteModel siteModel = siteRenderingContext.getSiteModel();
+ if (docRenderingContext != null
+ && siteRenderingContext.isDeriveBreadcrumbsFromMenu()
+ && siteModel != null
+ && siteModel.getBody() != null
+ && siteModel.getBody().getBreadcrumbs().isEmpty()) {
+ List<LinkItem> derivedBreadcrumbs =
+ MenuBreadcrumbs.derive(siteModel.getMenus(), docRenderingContext.getOutputPath());
+ if (!derivedBreadcrumbs.isEmpty()) {
+ // clone: the site model is shared across all documents of this site rendering,
+ // and the derived trail is specific to this one document
+ siteModel = siteModel.clone();
+ siteModel.getBody().setBreadcrumbs(derivedBreadcrumbs);
+ }
+ }
+ context.put("site", siteModel);
// TODO Deprecated -- will be removed!
- context.put("decoration", siteRenderingContext.getSiteModel());
+ context.put("decoration", siteModel);
context.put("locale", siteRenderingContext.getLocale());
context.put("supportedLocales", Collections.unmodifiableList(siteRenderingContext.getSiteLocales()));
diff --git a/doxia-site-renderer/src/main/java/org/apache/maven/doxia/siterenderer/MenuBreadcrumbs.java b/doxia-site-renderer/src/main/java/org/apache/maven/doxia/siterenderer/MenuBreadcrumbs.java
new file mode 100644
index 0000000..77fedbf
--- /dev/null
+++ b/doxia-site-renderer/src/main/java/org/apache/maven/doxia/siterenderer/MenuBreadcrumbs.java
@@ -0,0 +1,131 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.doxia.siterenderer;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import org.apache.maven.doxia.site.LinkItem;
+import org.apache.maven.doxia.site.Menu;
+import org.apache.maven.doxia.site.MenuItem;
+import org.apache.maven.doxia.site.SiteUtils;
+
+/**
+ * Derives a breadcrumb trail for a document from the site descriptor's menu hierarchy, for use on
+ * documents which do not declare an explicit <code><breadcrumbs></code> element.
+ *
+ * <p>This only walks the {@link Menu#getItems()} it is given: any <code>ref</code>,
+ * <code>inherit</code> or <code>inheritAsRef</code> attribute on a {@link Menu} is expected to
+ * already be resolved into concrete {@link MenuItem}s by the caller (as done by the Maven Site
+ * Plugin's site tool before the site model reaches the renderer). A menu which is still an
+ * unresolved reference simply contributes no items to search.</p>
+ *
+ * @since 2.1.1
+ */
+class MenuBreadcrumbs {
+
+ private MenuBreadcrumbs() {}
+
+ /**
+ * Locate {@code outputPath} among the {@code href} of the given menus' items (searched
+ * recursively through nested {@code <item>} elements), and build a breadcrumb trail from the
+ * {@code name} of the enclosing menu followed by the {@code name} of each enclosing item, from
+ * the outermost to the innermost. The located item itself is not included in the trail, matching
+ * how an explicit <code><breadcrumbs></code> element is conventionally written (listing only
+ * the ancestors of the current page).
+ *
+ * @param menus the site's menus, not null
+ * @param outputPath the current document's output path, relative to the site root (see
+ * {@link DocumentRenderingContext#getOutputPath()}), not null
+ * @return the derived breadcrumb trail, outermost first; empty if {@code outputPath} is not found
+ * in any menu item
+ */
+ static List<LinkItem> derive(List<Menu> menus, String outputPath) {
+ for (Menu menu : menus) {
+ List<LinkItem> trail = new ArrayList<>();
+ if (menu.getName() != null) {
+ LinkItem menuLink = new LinkItem();
+ menuLink.setName(menu.getName());
+ trail.add(menuLink);
+ }
+ if (find(menu.getItems(), outputPath, trail)) {
+ return trail;
+ }
+ }
+ return Collections.emptyList();
+ }
+
+ /**
+ * Recursively search {@code items} (and their nested items) for one whose href matches
+ * {@code outputPath}. While descending, every item visited on the current path is appended to
+ * {@code trail}; if the search backtracks without finding a match, those entries are removed
+ * again so that {@code trail} only ever holds ancestors of a found item.
+ */
+ private static boolean find(List<MenuItem> items, String outputPath, List<LinkItem> trail) {
+ if (items == null) {
+ return false;
+ }
+ for (MenuItem item : items) {
+ if (matches(item.getHref(), outputPath)) {
+ return true;
+ }
+
+ int sizeBeforeDescending = trail.size();
+ LinkItem itemLink = new LinkItem();
+ itemLink.setName(item.getName());
+ itemLink.setHref(item.getHref());
+ trail.add(itemLink);
+
+ if (find(item.getItems(), outputPath, trail)) {
+ return true;
+ }
+
+ // this item's branch did not contain outputPath: backtrack
+ while (trail.size() > sizeBeforeDescending) {
+ trail.remove(trail.size() - 1);
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Compare a menu item's href to the current document's output path. Both are expected to be
+ * relative to the site root: a leading <code>/</code> or <code>./</code> is stripped from the
+ * href before comparison. External links (as recognized by {@link SiteUtils#isLink(String)}) and
+ * hrefs containing a fragment (<code>#</code>) or query never match. This is a conservative,
+ * literal comparison: hrefs written with <code>../</code> segments, or otherwise not relative to
+ * the site root, will not be matched.
+ */
+ private static boolean matches(String href, String outputPath) {
+ if (href == null || href.isEmpty() || SiteUtils.isLink(href)) {
+ return false;
+ }
+ if (href.indexOf('#') >= 0 || href.indexOf('?') >= 0) {
+ return false;
+ }
+ String normalized = href;
+ if (normalized.startsWith("./")) {
+ normalized = normalized.substring(2);
+ } else if (normalized.startsWith("/")) {
+ normalized = normalized.substring(1);
+ }
+ return normalized.equals(outputPath);
+ }
+}
diff --git a/doxia-site-renderer/src/main/java/org/apache/maven/doxia/siterenderer/SiteRenderingContext.java b/doxia-site-renderer/src/main/java/org/apache/maven/doxia/siterenderer/SiteRenderingContext.java
index f9bce86..f8c2c53 100644
--- a/doxia-site-renderer/src/main/java/org/apache/maven/doxia/siterenderer/SiteRenderingContext.java
+++ b/doxia-site-renderer/src/main/java/org/apache/maven/doxia/siterenderer/SiteRenderingContext.java
@@ -139,6 +139,8 @@ public class SiteRenderingContext {
private boolean validate;
+ private boolean deriveBreadcrumbsFromMenu;
+
private Date publishDate;
private File processedContentOutput;
@@ -168,6 +170,29 @@ public class SiteRenderingContext {
this.validate = validate;
}
+ /**
+ * If breadcrumbs should be derived from the site descriptor's menu hierarchy for documents which
+ * declare no explicit <code><breadcrumbs></code> element.
+ * By default this is switched off: a document without an explicit <code><breadcrumbs></code>
+ * element then renders with an empty breadcrumb trail, as it always has.
+ *
+ * @return true if breadcrumbs should be derived from the menu hierarchy.
+ * @since 2.1.1
+ */
+ public boolean isDeriveBreadcrumbsFromMenu() {
+ return deriveBreadcrumbsFromMenu;
+ }
+
+ /**
+ * Switch on/off deriving breadcrumbs from the site descriptor's menu hierarchy.
+ *
+ * @param deriveBreadcrumbsFromMenu true to switch on breadcrumb derivation.
+ * @since 2.1.1
+ */
+ public void setDeriveBreadcrumbsFromMenu(boolean deriveBreadcrumbsFromMenu) {
+ this.deriveBreadcrumbsFromMenu = deriveBreadcrumbsFromMenu;
+ }
+
/**
* <p>Getter for the field <code>templateName</code>.</p>
*
diff --git a/doxia-site-renderer/src/test/java/org/apache/maven/doxia/siterenderer/DeriveBreadcrumbsFromMenuTest.java b/doxia-site-renderer/src/test/java/org/apache/maven/doxia/siterenderer/DeriveBreadcrumbsFromMenuTest.java
new file mode 100644
index 0000000..71cf984
--- /dev/null
+++ b/doxia-site-renderer/src/test/java/org/apache/maven/doxia/siterenderer/DeriveBreadcrumbsFromMenuTest.java
@@ -0,0 +1,152 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.doxia.siterenderer;
+
+import javax.inject.Inject;
+
+import java.io.File;
+import java.util.Collections;
+import java.util.List;
+
+import org.apache.maven.doxia.site.Body;
+import org.apache.maven.doxia.site.LinkItem;
+import org.apache.maven.doxia.site.Menu;
+import org.apache.maven.doxia.site.MenuItem;
+import org.apache.maven.doxia.site.SiteModel;
+import org.apache.velocity.context.Context;
+import org.codehaus.plexus.PlexusContainer;
+import org.codehaus.plexus.testing.PlexusTest;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Tests for the {@link SiteRenderingContext#isDeriveBreadcrumbsFromMenu()} flag, wired through
+ * {@link DefaultSiteRenderer#createDocumentVelocityContext(DocumentRenderingContext, SiteRenderingContext)}.
+ */
+@PlexusTest
+class DeriveBreadcrumbsFromMenuTest {
+
+ @Inject
+ private PlexusContainer container;
+
+ private DefaultSiteRenderer siteRenderer;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ siteRenderer = (DefaultSiteRenderer) container.lookup(SiteRenderer.class);
+ }
+
+ private static SiteModel siteModelWithMenu() {
+ MenuItem userGuide = new MenuItem();
+ userGuide.setName("User Guide");
+ userGuide.setHref("guides/user/index.html");
+
+ Menu menu = new Menu();
+ menu.setName("Documentation");
+ menu.setItems(Collections.singletonList(userGuide));
+
+ Body body = new Body();
+ body.setMenus(Collections.singletonList(menu));
+
+ SiteModel siteModel = new SiteModel();
+ siteModel.setBody(body);
+ return siteModel;
+ }
+
+ private static DocumentRenderingContext documentAt(String outputPathWithoutHtmlExtension) {
+ return new DocumentRenderingContext(new File("."), outputPathWithoutHtmlExtension, "generator");
+ }
+
+ @Test
+ void flagOffLeavesSiteModelUntouched() {
+ SiteModel siteModel = siteModelWithMenu();
+ SiteRenderingContext siteRenderingContext = new SiteRenderingContext();
+ siteRenderingContext.setSiteModel(siteModel);
+ siteRenderingContext.setDeriveBreadcrumbsFromMenu(false);
+
+ Context context =
+ siteRenderer.createDocumentVelocityContext(documentAt("guides/user/index"), siteRenderingContext);
+
+ // the exact same instance is exposed: nothing was derived, nothing was cloned
+ assertSame(siteModel, context.get("site"));
+ assertSame(siteModel, context.get("decoration"));
+ assertTrue(siteModel.getBody().getBreadcrumbs().isEmpty());
+ }
+
+ @Test
+ void flagOnDerivesBreadcrumbsWhenNoneDeclared() {
+ SiteModel siteModel = siteModelWithMenu();
+ SiteRenderingContext siteRenderingContext = new SiteRenderingContext();
+ siteRenderingContext.setSiteModel(siteModel);
+ siteRenderingContext.setDeriveBreadcrumbsFromMenu(true);
+
+ Context context =
+ siteRenderer.createDocumentVelocityContext(documentAt("guides/user/index"), siteRenderingContext);
+
+ SiteModel exposed = (SiteModel) context.get("site");
+ assertSame(exposed, context.get("decoration"));
+
+ List<LinkItem> breadcrumbs = exposed.getBody().getBreadcrumbs();
+ assertEquals(1, breadcrumbs.size());
+ assertEquals("Documentation", breadcrumbs.get(0).getName());
+
+ // the original site model, shared across every document of this rendering, is untouched
+ assertTrue(siteModel.getBody().getBreadcrumbs().isEmpty());
+ }
+
+ @Test
+ void explicitBreadcrumbsWinOverDerivation() {
+ SiteModel siteModel = siteModelWithMenu();
+ LinkItem explicit = new LinkItem();
+ explicit.setName("Explicit crumb");
+ siteModel.getBody().setBreadcrumbs(Collections.singletonList(explicit));
+
+ SiteRenderingContext siteRenderingContext = new SiteRenderingContext();
+ siteRenderingContext.setSiteModel(siteModel);
+ siteRenderingContext.setDeriveBreadcrumbsFromMenu(true);
+
+ Context context =
+ siteRenderer.createDocumentVelocityContext(documentAt("guides/user/index"), siteRenderingContext);
+
+ // the declared breadcrumbs are used as-is, no derivation is attempted, no clone is made
+ assertSame(siteModel, context.get("site"));
+ List<LinkItem> breadcrumbs = ((SiteModel) context.get("site")).getBody().getBreadcrumbs();
+ assertEquals(1, breadcrumbs.size());
+ assertEquals("Explicit crumb", breadcrumbs.get(0).getName());
+ }
+
+ @Test
+ void pageAbsentFromEveryMenuLeavesBreadcrumbsEmpty() {
+ SiteModel siteModel = siteModelWithMenu();
+ SiteRenderingContext siteRenderingContext = new SiteRenderingContext();
+ siteRenderingContext.setSiteModel(siteModel);
+ siteRenderingContext.setDeriveBreadcrumbsFromMenu(true);
+
+ Context context =
+ siteRenderer.createDocumentVelocityContext(documentAt("not-in-any-menu/index"), siteRenderingContext);
+
+ // nothing was found: same instance is exposed, exactly as when the flag is off
+ assertSame(siteModel, context.get("site"));
+ assertTrue(siteModel.getBody().getBreadcrumbs().isEmpty());
+ }
+}
diff --git a/doxia-site-renderer/src/test/java/org/apache/maven/doxia/siterenderer/MenuBreadcrumbsTest.java b/doxia-site-renderer/src/test/java/org/apache/maven/doxia/siterenderer/MenuBreadcrumbsTest.java
new file mode 100644
index 0000000..360e77f
--- /dev/null
+++ b/doxia-site-renderer/src/test/java/org/apache/maven/doxia/siterenderer/MenuBreadcrumbsTest.java
@@ -0,0 +1,161 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.doxia.siterenderer;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import org.apache.maven.doxia.site.LinkItem;
+import org.apache.maven.doxia.site.Menu;
+import org.apache.maven.doxia.site.MenuItem;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class MenuBreadcrumbsTest {
+
+ private static MenuItem item(String name, String href, MenuItem... children) {
+ MenuItem item = new MenuItem();
+ item.setName(name);
+ item.setHref(href);
+ if (children.length > 0) {
+ item.setItems(Arrays.asList(children));
+ }
+ return item;
+ }
+
+ private static Menu menu(String name, MenuItem... items) {
+ Menu menu = new Menu();
+ menu.setName(name);
+ menu.setItems(Arrays.asList(items));
+ return menu;
+ }
+
+ private static void assertTrail(List<LinkItem> trail, String... expectedNames) {
+ List<String> names = new ArrayList<>();
+ for (LinkItem link : trail) {
+ names.add(link.getName());
+ }
+ assertEquals(Arrays.asList(expectedNames), names);
+ }
+
+ @Test
+ void oneLevelMatch() {
+ List<Menu> menus = Collections.singletonList(
+ menu("Overview", item("Introduction", "index.html"), item("Download", "download.html")));
+
+ List<LinkItem> trail = MenuBreadcrumbs.derive(menus, "download.html");
+
+ // only the enclosing menu is an ancestor; the matched item itself is not included
+ assertTrail(trail, "Overview");
+ assertEquals(null, trail.get(0).getHref()); // a Menu has no href of its own
+ }
+
+ @Test
+ void nestedItemChainTwoLevelsDeep() {
+ MenuItem userGuide = item("User Guide", "guides/user/index.html");
+ MenuItem guides = item("Guides", null, userGuide);
+ List<Menu> menus = Collections.singletonList(menu("Documentation", guides));
+
+ List<LinkItem> trail = MenuBreadcrumbs.derive(menus, "guides/user/index.html");
+
+ assertTrail(trail, "Documentation", "Guides");
+ assertEquals(null, trail.get(1).getHref()); // "Guides" has no href of its own, only nested items
+ }
+
+ @Test
+ void nestedItemChainThreeLevelsDeep() {
+ MenuItem leaf = item("Configuration", "guides/user/advanced/configuration.html");
+ MenuItem advanced = item("Advanced", "guides/user/advanced/index.html", leaf);
+ MenuItem userGuide = item("User Guide", "guides/user/index.html", advanced);
+ List<Menu> menus = Collections.singletonList(menu("Documentation", userGuide));
+
+ List<LinkItem> trail = MenuBreadcrumbs.derive(menus, "guides/user/advanced/configuration.html");
+
+ assertTrail(trail, "Documentation", "User Guide", "Advanced");
+ assertEquals("guides/user/index.html", trail.get(1).getHref());
+ assertEquals("guides/user/advanced/index.html", trail.get(2).getHref());
+ }
+
+ @Test
+ void pageAbsentFromEveryMenu() {
+ List<Menu> menus = Collections.singletonList(menu("Overview", item("Introduction", "index.html")));
+
+ List<LinkItem> trail = MenuBreadcrumbs.derive(menus, "not-in-any-menu.html");
+
+ assertTrue(trail.isEmpty());
+ }
+
+ @Test
+ void noMenusAtAll() {
+ assertTrue(MenuBreadcrumbs.derive(Collections.emptyList(), "index.html").isEmpty());
+ }
+
+ @Test
+ void secondMenuContainsTheMatch() {
+ List<Menu> menus = Arrays.asList(
+ menu("Overview", item("Introduction", "index.html")),
+ menu("Documentation", item("User Guide", "guides/user/index.html")));
+
+ List<LinkItem> trail = MenuBreadcrumbs.derive(menus, "guides/user/index.html");
+
+ assertTrail(trail, "Documentation");
+ }
+
+ @Test
+ void hrefWithLeadingSlashMatches() {
+ List<Menu> menus = Collections.singletonList(menu("Overview", item("Introduction", "/index.html")));
+
+ assertTrail(MenuBreadcrumbs.derive(menus, "index.html"), "Overview");
+ }
+
+ @Test
+ void hrefWithLeadingDotSlashMatches() {
+ List<Menu> menus = Collections.singletonList(menu("Overview", item("Introduction", "./index.html")));
+
+ assertTrail(MenuBreadcrumbs.derive(menus, "index.html"), "Overview");
+ }
+
+ @Test
+ void externalHrefNeverMatches() {
+ List<Menu> menus = Collections.singletonList(menu("Overview", item("Apache", "https://apache.org/index.html")));
+
+ assertTrue(MenuBreadcrumbs.derive(menus, "index.html").isEmpty());
+ }
+
+ @Test
+ void hrefWithFragmentNeverMatches() {
+ List<Menu> menus = Collections.singletonList(menu("Overview", item("Introduction", "index.html#top")));
+
+ assertTrue(MenuBreadcrumbs.derive(menus, "index.html").isEmpty());
+ }
+
+ @Test
+ void itemWithoutHrefIsStillWalkedForItsChildren() {
+ // a pure category item (no href of its own) whose child matches
+ MenuItem child = item("Child", "category/child.html");
+ MenuItem category = item("Category", null, child);
+ List<Menu> menus = Collections.singletonList(menu("Overview", category));
+
+ assertTrail(MenuBreadcrumbs.derive(menus, "category/child.html"), "Overview", "Category");
+ }
+}