diff --git a/src/Elastic.Documentation.Site/_ViewModels.cs b/src/Elastic.Documentation.Site/_ViewModels.cs
index 7bbc4db766..3419192aaf 100644
--- a/src/Elastic.Documentation.Site/_ViewModels.cs
+++ b/src/Elastic.Documentation.Site/_ViewModels.cs
@@ -52,6 +52,12 @@ public record GlobalLayoutViewModel
///
Breadcrumb trail for codex sub-header (Home / Group / Docset).
public IReadOnlyList
? CodexBreadcrumbs { get; init; }
+ ///
+ /// The configured top navigation for assembler builds. When null the secondary nav renders
+ /// its built-in links instead.
+ ///
+ public TopNavRenderModel? TopNav { get; init; }
+
///
/// When the current page is a hidden nav item (e.g. an individual detection rule page),
/// the URL of its nearest visible ancestor. The client uses this to highlight the correct
diff --git a/src/Elastic.Documentation.Site/synthetics/journeys/navigation-test.journey.ts b/src/Elastic.Documentation.Site/synthetics/journeys/navigation-test.journey.ts
index 1386d044a7..c883a55480 100644
--- a/src/Elastic.Documentation.Site/synthetics/journeys/navigation-test.journey.ts
+++ b/src/Elastic.Documentation.Site/synthetics/journeys/navigation-test.journey.ts
@@ -176,11 +176,9 @@ journey('navigation test', ({ page, params }) => {
expect(state.treeShowsNewGroup).toBe(true)
})
- step('Use dropdown to navigate to reference', async () => {
- const pagesDropdown = page.locator('#pages-dropdown')
- const svg = pagesDropdown.locator('svg')
- await svg.click()
- await pagesDropdown
+ step('Navigate to reference via top nav', async () => {
+ await page
+ .locator('#secondary-nav')
.getByRole('link', { name: 'Reference', exact: true })
.click()
await expect(page).toHaveURL(`${host}/docs/reference`)
diff --git a/src/Elastic.Markdown/HtmlWriter.cs b/src/Elastic.Markdown/HtmlWriter.cs
index 40cd683c0e..62fe04ebbd 100644
--- a/src/Elastic.Markdown/HtmlWriter.cs
+++ b/src/Elastic.Markdown/HtmlWriter.cs
@@ -196,6 +196,7 @@ private async Task RenderLayout(MarkdownFile markdown, MarkdownDoc
GoogleTagManager = DocumentationSet.Context.GoogleTagManager,
Optimizely = DocumentationSet.Context.Optimizely,
Features = DocumentationSet.Configuration.Features,
+ TopNav = DocumentationSet.Context.TopNav,
StaticFileContentHashProvider = StaticFileContentHashProvider,
ReportIssueUrl = reportUrl,
CurrentVersion = currentBaseVersion,
diff --git a/src/Elastic.Markdown/Page/Index.cshtml b/src/Elastic.Markdown/Page/Index.cshtml
index d7040b304f..132bdb544c 100644
--- a/src/Elastic.Markdown/Page/Index.cshtml
+++ b/src/Elastic.Markdown/Page/Index.cshtml
@@ -37,6 +37,7 @@
GoogleTagManager = Model.GoogleTagManager,
Optimizely = Model.Optimizely,
Features = Model.Features,
+ TopNav = Model.TopNav,
StaticFileContentHashProvider = Model.StaticFileContentHashProvider,
ReportIssueUrl = Model.ReportIssueUrl,
Breadcrumbs = Model.Breadcrumbs,
diff --git a/src/Elastic.Markdown/Page/IndexViewModel.cs b/src/Elastic.Markdown/Page/IndexViewModel.cs
index e2644f6188..984a4f4786 100644
--- a/src/Elastic.Markdown/Page/IndexViewModel.cs
+++ b/src/Elastic.Markdown/Page/IndexViewModel.cs
@@ -86,6 +86,9 @@ public class IndexViewModel
/// Codex sub-header breadcrumb trail (Home / Group / Docset).
public IReadOnlyList? CodexBreadcrumbs { get; set; }
+ /// The configured site-wide top navigation. Null outside assembler builds.
+ public TopNavRenderModel? TopNav { get; init; }
+
/// When set, the page performs a client-side redirect to this URL (used for alias pages).
public string? RedirectUrl { get; init; }
diff --git a/src/services/Elastic.Documentation.Assembler/AssembleSources.cs b/src/services/Elastic.Documentation.Assembler/AssembleSources.cs
index 0b3d4cdd63..cc32914279 100644
--- a/src/services/Elastic.Documentation.Assembler/AssembleSources.cs
+++ b/src/services/Elastic.Documentation.Assembler/AssembleSources.cs
@@ -32,6 +32,8 @@ public class AssembleSources
public PublishEnvironmentUriResolver UriResolver { get; }
+ public ICrossLinkResolver CrossLinkResolver { get; }
+
public static async Task AssembleAsync(
ILoggerFactory logFactory,
AssembleContext context,
@@ -107,6 +109,7 @@ IReadOnlySet availableExporters
NavigationTocMappings = navigationTocMappings;
LegacyUrlMappings = legacyUrlMappings;
UriResolver = uriResolver;
+ CrossLinkResolver = crossLinkResolver;
AssembleContext = assembleContext;
AssembleSets = checkouts
.Where(c => c.Repository is { Skip: false })
@@ -179,11 +182,15 @@ static void ReadBlock(
string? repository = null;
string? source = null;
string? pathPrefix = null;
+ var isSection = false;
foreach (var entry in tocEntry.Children)
{
var key = ((YamlScalarNode)entry.Key).Value;
switch (key)
{
+ case "section":
+ isSection = true;
+ break;
case "toc":
source = reader.ReadString(entry);
if (source.AsSpan().IndexOf("://") == -1)
@@ -212,7 +219,20 @@ static void ReadBlock(
}
if (source is null)
+ {
+ // section: entries have no source; descend into their children so the children's
+ // sources are registered in NavigationTocMappings.
+ if (isSection)
+ {
+ foreach (var entry in tocEntry.Children)
+ {
+ var key = ((YamlScalarNode)entry.Key).Value;
+ if (key == "children")
+ ReadTocBlocks(entries, reader, entry, parent, depth, topLevelSource, parentSource);
+ }
+ }
return;
+ }
source = source.EndsWith("://", StringComparison.OrdinalIgnoreCase) ? source : source.TrimEnd('/') + "/";
if (!Uri.TryCreate(source, UriKind.Absolute, out var sourceUri))
diff --git a/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildService.cs b/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildService.cs
index 610d47272b..dba0646009 100644
--- a/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildService.cs
+++ b/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildService.cs
@@ -114,6 +114,10 @@ Cancel ctx
if (!SiteNavigationFile.ValidatePathPrefixes(assembleContext.Collector, siteNavigationFile, navigationFileInfo) || assembleContext.Collector.Errors > 0)
return false;
+ var topNav = SectionTopNavBuilder.Build(navigation, siteNavigationFile);
+ foreach (var set in assembleSources.AssembleSets.Values)
+ set.BuildContext.TopNav = topNav;
+
var pathProvider = new GlobalNavigationPathProvider(navigation, assembleSources, assembleContext);
var htmlWriter = new GlobalNavigationHtmlWriter(logFactory, navigation, collector);
var legacyPageChecker = new LegacyPageService(logFactory);
diff --git a/src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigationHtmlWriter.cs b/src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigationHtmlWriter.cs
index 028d4abc67..9700aede33 100644
--- a/src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigationHtmlWriter.cs
+++ b/src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigationHtmlWriter.cs
@@ -46,7 +46,7 @@ private NavigationRenderModel CreateNavigationModel(INodeNavigationItem !item.Hidden).ToArray();
+ var guideItems = navigation.TopLevelItems.Where(item => !item.Hidden).ToArray();
- foreach (var topLevelItem in topLevelItems)
+ foreach (var group in guideItems)
{
- if (topLevelItem is not { } group)
- continue;
-
// Create H2 section for the category - use H1 title if available, fallback to navigation title
var categoryTitle = GetBestTitle(group);
_ = content.AppendLine(CultureInfo.InvariantCulture, $"## {categoryTitle}");
diff --git a/src/services/Elastic.Documentation.Assembler/Navigation/SectionTopNavBuilder.cs b/src/services/Elastic.Documentation.Assembler/Navigation/SectionTopNavBuilder.cs
new file mode 100644
index 0000000000..6adc3f76a9
--- /dev/null
+++ b/src/services/Elastic.Documentation.Assembler/Navigation/SectionTopNavBuilder.cs
@@ -0,0 +1,84 @@
+// Licensed to Elasticsearch B.V under one or more agreements.
+// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
+// See the LICENSE file in the project root for more information
+
+using Elastic.Documentation.Configuration.Toc;
+using Elastic.Documentation.Navigation;
+using Elastic.Documentation.Navigation.Assembler;
+
+namespace Elastic.Documentation.Assembler.Navigation;
+
+///
+/// Builds a from the top-level navigation entries in
+/// navigation.yml. Supports two entry shapes:
+///
+/// - toc: — a single navigation root, becomes one tab.
+/// - section: — a named group of toc: refs, becomes one tab whose active state
+/// matches any of the grouped roots. External sections become external-link tabs.
+///
+/// Active state is determined by comparing the current page's NavigationRoot.Id to each
+/// tab's stored (or
+/// for single-root tabs).
+///
+public static class SectionTopNavBuilder
+{
+ public static TopNavRenderModel? Build(SiteNavigation navigation, SiteNavigationFile navFile)
+ {
+ var topLevel = navigation.TopLevelItems;
+ if (navFile.TableOfContents.Count == 0)
+ return null;
+
+ // Index plain toc: items by Identifier for fast lookup.
+ // Sections with children now live in the tree as SectionNavigation nodes and
+ // are looked up by title instead.
+ var byIdentifier = topLevel
+ .OfType>()
+ .Where(item => item is not SectionNavigation)
+ .ToDictionary(item => item.Identifier);
+
+ var sectionsByTitle = topLevel
+ .OfType()
+ .ToDictionary(s => s.Title, StringComparer.OrdinalIgnoreCase);
+
+ var items = new List();
+
+ foreach (var entry in navFile.TableOfContents)
+ {
+ if (entry is SiteSectionRef section)
+ {
+ if (section.IsExternal)
+ {
+ items.Add(new TopNavLinkItem(section.Title, section.ExternalUrl!, IsExternal: true));
+ }
+ else if (sectionsByTitle.TryGetValue(section.Title, out var sectionNav))
+ {
+ // All pages within the section have NavigationRoot = sectionNav,
+ // so a single SectionId match is sufficient for active-tab detection.
+ var tabUrl = sectionNav.NavigationItems
+ .OfType>()
+ .FirstOrDefault()?.Index.Url;
+
+ if (tabUrl is not null)
+ {
+ items.Add(new TopNavLinkItem(section.Title, tabUrl, IsExternal: false,
+ SectionId: sectionNav.Id));
+ }
+ }
+ }
+ else if (entry is SiteTableOfContentsRef tocRef)
+ {
+ // Plain toc: entry — one tab, active when NavigationRoot.Id == item.Id
+ if (byIdentifier.TryGetValue(tocRef.Source, out var navItem))
+ {
+ items.Add(new TopNavLinkItem(
+ navItem.NavigationTitle,
+ navItem.Index.Url,
+ IsExternal: false,
+ SectionId: navItem.Id));
+ }
+ }
+ }
+
+ return items.Count > 0 ? new TopNavRenderModel(items) : null;
+ }
+}
diff --git a/tests/Elastic.Documentation.Configuration.Tests/SiteNavigationFileTests.cs b/tests/Elastic.Documentation.Configuration.Tests/SiteNavigationFileTests.cs
index 75efcd2996..ab502acf33 100644
--- a/tests/Elastic.Documentation.Configuration.Tests/SiteNavigationFileTests.cs
+++ b/tests/Elastic.Documentation.Configuration.Tests/SiteNavigationFileTests.cs
@@ -35,16 +35,16 @@ public void DeserializesSiteNavigationFile()
siteNav.TableOfContents.Should().HaveCount(3);
- var observability = siteNav.TableOfContents.ElementAt(0);
+ var observability = siteNav.TableOfContents.ElementAt(0).Should().BeOfType().Which;
observability.Source.ToString().Should().Be("docs-content://serverless/observability");
observability.PathPrefix.Should().Be("/serverless/observability");
observability.Children.Should().BeEmpty();
- var search = siteNav.TableOfContents.ElementAt(1);
+ var search = siteNav.TableOfContents.ElementAt(1).Should().BeOfType().Which;
search.Source.ToString().Should().Be("docs-content://serverless/search");
search.PathPrefix.Should().Be("/serverless/search");
- var security = siteNav.TableOfContents.ElementAt(2);
+ var security = siteNav.TableOfContents.ElementAt(2).Should().BeOfType().Which;
security.Source.ToString().Should().Be("docs-content://serverless/security");
security.PathPrefix.Should().Be("/serverless/security");
}
@@ -68,7 +68,7 @@ public void DeserializesSiteNavigationFileWithNestedChildren()
siteNav.TableOfContents.Should().HaveCount(1);
- var platform = siteNav.TableOfContents.First();
+ var platform = siteNav.TableOfContents.First().Should().BeOfType().Which;
platform.Source.ToString().Should().Be("docs-content://platform/");
platform.PathPrefix.Should().Be("/platform");
platform.Children.Should().HaveCount(2);
@@ -94,7 +94,7 @@ public void DeserializesWithMissingPath()
var siteNav = SiteNavigationFile.Deserialize(yaml);
siteNav.TableOfContents.Should().HaveCount(1);
- var ref1 = siteNav.TableOfContents.First();
+ var ref1 = siteNav.TableOfContents.First().Should().BeOfType().Which;
ref1.Source.ToString().Should().Be("docs-content://elasticsearch/reference");
ref1.PathPrefix.Should().BeEmpty();
}
@@ -114,16 +114,13 @@ public void PreservesSchemeWhenPresent()
siteNav.TableOfContents.Should().HaveCount(3);
- // With elasticsearch:// scheme
- var elasticsearch = siteNav.TableOfContents.ElementAt(0);
+ var elasticsearch = siteNav.TableOfContents.ElementAt(0).Should().BeOfType().Which;
elasticsearch.Source.ToString().Should().Be("elasticsearch://reference/current");
- // With kibana:// scheme
- var kibana = siteNav.TableOfContents.ElementAt(1);
+ var kibana = siteNav.TableOfContents.ElementAt(1).Should().BeOfType().Which;
kibana.Source.ToString().Should().Be("kibana://reference/8.0");
- // Without scheme - should get docs-content://
- var serverless = siteNav.TableOfContents.ElementAt(2);
+ var serverless = siteNav.TableOfContents.ElementAt(2).Should().BeOfType().Which;
serverless.Source.ToString().Should().Be("docs-content://serverless/observability");
}
@@ -144,10 +141,10 @@ public void DeserializesIslandOnTocEntry()
siteNav.TableOfContents.Should().HaveCount(2);
- var observability = siteNav.TableOfContents.ElementAt(0);
+ var observability = siteNav.TableOfContents.ElementAt(0).Should().BeOfType().Which;
observability.Island.Should().BeTrue("island: true must be captured on the SiteTableOfContentsRef");
- var security = siteNav.TableOfContents.ElementAt(1);
+ var security = siteNav.TableOfContents.ElementAt(1).Should().BeOfType().Which;
security.Island.Should().BeFalse("island defaults to false when omitted");
}
@@ -166,4 +163,93 @@ public void ThrowsExceptionForInvalidUri()
.WithInnerException()
.WithMessage("Invalid TOC source: '://invalid' could not be parsed as a URI");
}
+
+ [Fact]
+ public void UnknownKeyThrows()
+ {
+ // language=yaml
+ var yaml = """
+ toc:
+ - tocc: typo
+ """;
+
+ // A typo (no 'toc:' or 'section:' key) must throw rather than silently drop the entry.
+ var act = () => SiteNavigationFile.Deserialize(yaml);
+
+ act.Should().Throw()
+ .WithMessage("*has no 'toc:' key*");
+ }
+
+ [Fact]
+ public void DeserializesSectionWithChildren()
+ {
+ // language=yaml
+ var yaml = """
+ toc:
+ - section: Guides
+ children:
+ - toc: get-started
+ - toc: solutions
+ - toc: reference
+ path_prefix: reference
+ """;
+
+ var siteNav = SiteNavigationFile.Deserialize(yaml);
+
+ siteNav.TableOfContents.Should().HaveCount(2);
+
+ var guides = siteNav.TableOfContents.ElementAt(0).Should().BeOfType().Which;
+ guides.Title.Should().Be("Guides");
+ guides.IsExternal.Should().BeFalse();
+ guides.ExternalUrl.Should().BeNull();
+ guides.Children.Should().HaveCount(2);
+ guides.Children.ElementAt(0).Source.ToString().Should().Be("docs-content://get-started/");
+ guides.Children.ElementAt(1).Source.ToString().Should().Be("docs-content://solutions/");
+
+ var reference = siteNav.TableOfContents.ElementAt(1).Should().BeOfType().Which;
+ reference.Source.ToString().Should().Be("docs-content://reference/");
+ }
+
+ [Fact]
+ public void DeserializesExternalSection()
+ {
+ // language=yaml
+ var yaml = """
+ toc:
+ - section: APIs
+ external: https://www.elastic.co/docs/api/
+ """;
+
+ var siteNav = SiteNavigationFile.Deserialize(yaml);
+
+ siteNav.TableOfContents.Should().HaveCount(1);
+
+ var apis = siteNav.TableOfContents.First().Should().BeOfType().Which;
+ apis.Title.Should().Be("APIs");
+ apis.IsExternal.Should().BeTrue();
+ apis.ExternalUrl.Should().Be("https://www.elastic.co/docs/api/");
+ apis.Children.Should().BeEmpty();
+ }
+
+ ///
+ /// The shipped config/navigation.yml must deserialize cleanly.
+ ///
+ [Fact]
+ public void ShippedNavigationYmlDeserializes()
+ {
+ var root = Paths.GetSolutionDirectory() ?? throw new InvalidOperationException("Solution directory not found.");
+ var path = Path.Combine(root.FullName, "config", "navigation.yml");
+ File.Exists(path).Should().BeTrue();
+
+ var siteNav = SiteNavigationFile.Deserialize(File.ReadAllText(path));
+
+ siteNav.TableOfContents.Should().NotBeEmpty();
+ foreach (var entry in siteNav.TableOfContents)
+ {
+ if (entry is SiteTableOfContentsRef tocRef)
+ tocRef.Source.Should().NotBeNull();
+ else if (entry is SiteSectionRef section)
+ section.Title.Should().NotBeNullOrEmpty();
+ }
+ }
}
diff --git a/tests/Navigation.Tests/Assembler/SectionNavigationTests.cs b/tests/Navigation.Tests/Assembler/SectionNavigationTests.cs
new file mode 100644
index 0000000000..58a9cb5e21
--- /dev/null
+++ b/tests/Navigation.Tests/Assembler/SectionNavigationTests.cs
@@ -0,0 +1,300 @@
+// Licensed to Elasticsearch B.V under one or more agreements.
+// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
+// See the LICENSE file in the project root for more information
+
+using System.IO.Abstractions.TestingHelpers;
+using AwesomeAssertions;
+using Elastic.Documentation.Assembler.Navigation;
+using Elastic.Documentation.Configuration;
+using Elastic.Documentation.Configuration.Toc;
+using Elastic.Documentation.FileSystems;
+using Elastic.Documentation.Navigation.Assembler;
+using Elastic.Documentation.Navigation.Isolated.Node;
+using Elastic.Documentation.Site.Navigation;
+
+namespace Elastic.Documentation.Navigation.Tests.Assembler;
+
+///
+/// Tests for the tree node created from
+/// section: entries with children: in navigation.yml.
+///
+public class SectionNavigationTests(ITestOutputHelper output)
+{
+ // ──────────────────────────────────────────────────────────────
+ // Helpers
+ // ──────────────────────────────────────────────────────────────
+
+ private static (SiteNavigation, DocumentationSetNavigation, DocumentationSetNavigation)
+ BuildTwoChildSection(ITestOutputHelper output, string siteNavYaml)
+ {
+ var siteNavFile = SiteNavigationFile.Deserialize(siteNavYaml);
+ var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem();
+
+ var obsCtx = SiteNavigationTestFixture.CreateAssemblerContext(fileSystem, "/checkouts/current/observability", output);
+ var obsDocset = DocumentationSetFile.LoadAndResolve(
+ obsCtx.Collector,
+ fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"),
+ new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem));
+ var obsNav = new DocumentationSetNavigation(obsDocset, obsCtx, GenericDocumentationFileFactory.Instance);
+
+ var searchCtx = SiteNavigationTestFixture.CreateAssemblerContext(fileSystem, "/checkouts/current/serverless-search", output);
+ var searchDocset = DocumentationSetFile.LoadAndResolve(
+ searchCtx.Collector,
+ fileSystem.FileInfo.New("/checkouts/current/serverless-search/docs/docset.yml"),
+ new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem));
+ var searchNav = new DocumentationSetNavigation(searchDocset, searchCtx, GenericDocumentationFileFactory.Instance);
+
+ var siteCtx = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output);
+ var navigation = new SiteNavigation(siteNavFile, siteCtx, [obsNav, searchNav], sitePrefix: "/docs");
+ return (navigation, obsNav, searchNav);
+ }
+
+ // ──────────────────────────────────────────────────────────────
+ // Tree structure
+ // ──────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void SectionWithChildren_CreatesSectionNavigationNode()
+ {
+ // language=yaml
+ var yaml = """
+ toc:
+ - section: Guides
+ children:
+ - toc: observability://
+ path_prefix: /observability
+ - toc: serverless-search://
+ path_prefix: /search
+ """;
+
+ var (nav, _, _) = BuildTwoChildSection(output, yaml);
+
+ // Top-level should have exactly one item: the SectionNavigation
+ nav.NavigationItems.Should().HaveCount(1);
+ var section = nav.NavigationItems.First()
+ .Should().BeOfType().Subject;
+
+ section.Title.Should().Be("Guides");
+ section.NavigationItems.Should().HaveCount(2);
+ }
+
+ [Fact]
+ public void SectionNavigationNode_IsIsland_AndParentIsSiteNavigation()
+ {
+ // language=yaml
+ var yaml = """
+ toc:
+ - section: Guides
+ children:
+ - toc: observability://
+ path_prefix: /observability
+ - toc: serverless-search://
+ path_prefix: /search
+ """;
+
+ var (nav, _, _) = BuildTwoChildSection(output, yaml);
+
+ var section = nav.NavigationItems.First()
+ .Should().BeOfType().Subject;
+
+ section.IsIsland.Should().BeTrue();
+ section.Parent.Should().BeSameAs(nav, "SectionNavigation parent must be SiteNavigation");
+ section.RendersAsIsland().Should().BeTrue("island + non-null parent");
+ }
+
+ [Fact]
+ public void SectionChildren_AreNotIslands_SectionIsTheIsland()
+ {
+ // language=yaml
+ var yaml = """
+ toc:
+ - section: Guides
+ children:
+ - toc: observability://
+ path_prefix: /observability
+ - toc: serverless-search://
+ path_prefix: /search
+ """;
+
+ var (nav, obsNav, searchNav) = BuildTwoChildSection(output, yaml);
+
+ var section = nav.NavigationItems.First().Should().BeOfType().Subject;
+
+ // Children are branches within the section island, not individual islands.
+ obsNav.IsIsland.Should().BeFalse("children of a section are not individual islands; the section owns the sidebar");
+ obsNav.Parent.Should().BeSameAs(section, "child docset parent must be SectionNavigation");
+ obsNav.RendersAsIsland().Should().BeFalse("child is not an island, even with a parent");
+
+ searchNav.IsIsland.Should().BeFalse();
+ searchNav.Parent.Should().BeSameAs(section);
+ }
+
+ // ──────────────────────────────────────────────────────────────
+ // FindIslandRoot: returns child docset, not the section
+ // ──────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void FindIslandRoot_FromDeepPage_ReturnsSectionNavigation()
+ {
+ // language=yaml
+ var yaml = """
+ toc:
+ - section: Guides
+ children:
+ - toc: observability://
+ path_prefix: /observability
+ - toc: serverless-search://
+ path_prefix: /search
+ """;
+
+ var (nav, _, _) = BuildTwoChildSection(output, yaml);
+
+ var section = nav.NavigationItems.First().Should().BeOfType().Subject;
+
+ // Pick a deep leaf inside the observability docset via NavigationIndexedByOrder
+ var deepLeaf = nav.NavigationIndexedByOrder.Values
+ .OfType>()
+ .FirstOrDefault(l => l.Url.Contains("monitoring"));
+ deepLeaf.Should().NotBeNull("fixture has monitoring/ pages");
+
+ var islandRoot = deepLeaf.FindIslandRoot();
+ islandRoot.Should().BeSameAs(section,
+ "FindIslandRoot walks past child docsets (not islands) and stops at the section island");
+ }
+
+ // ──────────────────────────────────────────────────────────────
+ // Back-link: immediate parent of obsNav is SectionNavigation
+ // ──────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void BackLink_FromSectionIsland_IncludesElasticDocs()
+ {
+ // language=yaml
+ var yaml = """
+ toc:
+ - section: Guides
+ children:
+ - toc: observability://
+ path_prefix: /observability
+ - toc: serverless-search://
+ path_prefix: /search
+ """;
+
+ var (nav, _, _) = BuildTwoChildSection(output, yaml);
+
+ var section = nav.NavigationItems.First().Should().BeOfType().Subject;
+
+ // The section IS the island; render its sidebar and check back-links
+ var renderModel = NavigationRenderModel.Create(
+ tree: section,
+ topLevelItems: nav.TopLevelItems,
+ isUsingNavigationDropdown: false,
+ isPrimaryNavEnabled: true,
+ isGlobalAssemblyBuild: true);
+
+ // Only ancestor above the section is SiteNavigation ("Elastic Docs")
+ renderModel.BackLinks.Should().Contain(link => link.Title == "Elastic Docs",
+ "the section's only ancestor is SiteNavigation");
+ renderModel.BackLinks.Should().NotContain(link => link.Title == "Guides",
+ "the section itself is not its own back-link");
+ }
+
+ // ──────────────────────────────────────────────────────────────
+ // URL invariance: section as root doesn't change child page URLs
+ // ──────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void ChildPageUrls_AreUnchanged_BySectionParent()
+ {
+ // language=yaml
+ var flat = """
+ toc:
+ - toc: observability://
+ path_prefix: /observability
+ - toc: serverless-search://
+ path_prefix: /search
+ """;
+
+ // language=yaml
+ var sectioned = """
+ toc:
+ - section: Guides
+ children:
+ - toc: observability://
+ path_prefix: /observability
+ - toc: serverless-search://
+ path_prefix: /search
+ """;
+
+ var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem();
+
+ string[] GetLeafUrls(string siteNavYaml)
+ {
+ var navFile = SiteNavigationFile.Deserialize(siteNavYaml);
+ var obsCtx = SiteNavigationTestFixture.CreateAssemblerContext(fileSystem, "/checkouts/current/observability", output);
+ var obsDocset = DocumentationSetFile.LoadAndResolve(
+ obsCtx.Collector,
+ fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"),
+ new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem));
+ var obsNav = new DocumentationSetNavigation(obsDocset, obsCtx, GenericDocumentationFileFactory.Instance);
+
+ var searchCtx = SiteNavigationTestFixture.CreateAssemblerContext(fileSystem, "/checkouts/current/serverless-search", output);
+ var searchDocset = DocumentationSetFile.LoadAndResolve(
+ searchCtx.Collector,
+ fileSystem.FileInfo.New("/checkouts/current/serverless-search/docs/docset.yml"),
+ new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem));
+ var searchNav = new DocumentationSetNavigation(searchDocset, searchCtx, GenericDocumentationFileFactory.Instance);
+
+ var siteCtx = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output);
+ var siteNav = new SiteNavigation(navFile, siteCtx, [obsNav, searchNav], sitePrefix: "/docs");
+ return [..siteNav.NavigationIndexedByOrder.Values
+ .OfType>()
+ .Select(l => l.Url)
+ .Order()];
+ }
+
+ var flatUrls = GetLeafUrls(flat);
+ var sectionedUrls = GetLeafUrls(sectioned);
+
+ // The set of leaf URLs must be identical regardless of whether entries
+ // are nested under a section or flat at the top level.
+ sectionedUrls.Should().BeEquivalentTo(flatUrls,
+ "grouping toc entries under a section must not change any page URL");
+ }
+
+ // ──────────────────────────────────────────────────────────────
+ // SectionTopNavBuilder: tab built from section node children
+ // ──────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void SectionTopNavBuilder_BuildsTab_WithSectionId()
+ {
+ // language=yaml
+ var yaml = """
+ toc:
+ - section: Guides
+ children:
+ - toc: observability://
+ path_prefix: /observability
+ - toc: serverless-search://
+ path_prefix: /search
+ """;
+
+ var (nav, _, _) = BuildTwoChildSection(output, yaml);
+ var navFile = SiteNavigationFile.Deserialize(yaml);
+
+ var section = nav.NavigationItems.First().Should().BeOfType().Subject;
+
+ var renderModel = SectionTopNavBuilder.Build(nav, navFile);
+
+ renderModel.Should().NotBeNull();
+ renderModel.Items.Should().HaveCount(1);
+
+ var tab = renderModel.Items[0].Should().BeOfType().Subject;
+ tab.Title.Should().Be("Guides");
+ // All section pages have NavigationRoot = sectionNav, so a single SectionId suffices
+ tab.SectionId.Should().Be(section.Id,
+ "active-tab detection matches NavigationRoot.Id == section.Id");
+ tab.SectionIds.Should().BeNull("multi-root SectionIds are not needed when the section is the island");
+ }
+}
diff --git a/tests/Navigation.Tests/Rendering/SecondaryNavRenderingTests.cs b/tests/Navigation.Tests/Rendering/SecondaryNavRenderingTests.cs
new file mode 100644
index 0000000000..9c7b0aa5ed
--- /dev/null
+++ b/tests/Navigation.Tests/Rendering/SecondaryNavRenderingTests.cs
@@ -0,0 +1,180 @@
+// Licensed to Elasticsearch B.V under one or more agreements.
+// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
+// See the LICENSE file in the project root for more information
+
+using System.IO.Abstractions.TestingHelpers;
+using AwesomeAssertions;
+using Elastic.Documentation.Configuration.Assembler;
+using Elastic.Documentation.Configuration.Builder;
+using Elastic.Documentation.Configuration.Toc;
+using Elastic.Documentation.Extensions;
+using Elastic.Documentation.Navigation.Tests.Isolation;
+using Elastic.Documentation.Site;
+using Elastic.Documentation.Site.FileProviders;
+using Elastic.Documentation.Site.Layout;
+using RazorSlices;
+
+namespace Elastic.Documentation.Navigation.Tests.Rendering;
+
+public class SecondaryNavRenderingTests(ITestOutputHelper output) : DocumentationSetNavigationTestBase(output)
+{
+ private const string ReferenceSectionId = "ref-section-id";
+
+ private static readonly TopNavRenderModel TopNav = new([
+ new TopNavLinkItem("Reference", "/docs/reference/", false, SectionId: ReferenceSectionId),
+ new TopNavLinkItem("APIs", "https://www.elastic.co/docs/api/", true),
+ new TopNavDropdownItem("Products", [
+ new TopNavGroup("Stack products", [
+ new TopNavLinkItem("Elasticsearch", "/docs/products/elasticsearch/", false)
+ ]),
+ new TopNavGroup(null, [
+ new TopNavLinkItem("All products", "/docs/products/", false)
+ ])
+ ])
+ ]);
+
+ [Fact]
+ public async Task WithoutConfigurationTheBuiltInLinksAreRendered()
+ {
+ var html = await Render(topNav: null, currentUrl: "/docs/");
+
+ html.Should().Contain("Release notes").And.Contain("Troubleshoot").And.Contain("Reference");
+ html.Should().NotContain("secondary-nav-dropdown");
+ html.Should().Contain("id=\"htmx-indicator\"");
+ }
+
+ [Fact]
+ public async Task ConfiguredLinksReplaceTheBuiltInOnes()
+ {
+ var html = await Render(TopNav, currentUrl: "/docs/");
+
+ html.Should().Contain("href=\"/docs/reference/\"");
+ // the built-in links are gone once top_nav is configured
+ html.Should().NotContain("Release notes").And.NotContain("Troubleshoot");
+ html.Should().Contain("id=\"htmx-indicator\"");
+ }
+
+ [Fact]
+ public async Task TheBarIsLeftAlignedAndCarriesNoBrandLink()
+ {
+ foreach (var html in new[] { await Render(TopNav, "/docs/"), await Render(null, "/docs/") })
+ {
+ html.Should().NotContain(">Docs<");
+ html.Should().Contain("justify-start").And.NotContain("justify-between");
+ }
+ }
+
+ [Fact]
+ public async Task ExternalLinksOpenInANewTab()
+ {
+ var html = await Render(TopNav, currentUrl: "/docs/");
+
+ html.Should().Contain("href=\"https://www.elastic.co/docs/api/\"");
+ html.Should().Contain("target=\"_blank\"");
+ html.Should().Contain("rel=\"noopener noreferrer\"");
+ html.Should().Contain("(opens in a new tab)");
+ }
+
+ [Fact]
+ public async Task DropdownRendersItsGroupsAndLinks()
+ {
+ var html = await Render(TopNav, currentUrl: "/docs/");
+
+ html.Should().Contain("");
+ html.Should().Contain("secondary-nav-dropdown-group-label\">Stack products");
+ html.Should().Contain("href=\"/docs/products/elasticsearch/\"");
+ html.Should().Contain("href=\"/docs/products/\"");
+ // the label toggles the panel, it is never a link itself
+ html.Should().NotContain(" li.Contains("Reference"));
+ referenceListItem.Should().Contain("text-blue-elastic").And.NotContain("hover:text-blue-elastic");
+
+ // Dropdown tabs have no tree backing — they are never marked active via section ID.
+ var product = await Render(TopNav, currentUrl: "/docs/products/elasticsearch/index");
+ var productListItem = product.Split(" li.Contains("Products"));
+ // "hover:text-blue-elastic" present means the inactive CSS variant is applied, not the active one.
+ productListItem.Should().Contain("hover:text-blue-elastic")
+ .And.NotContain("relative text-blue-elastic\"");
+ }
+
+ [Fact]
+ public async Task UnrelatedPagesLeaveEveryItemInactive()
+ {
+ var html = await Render(TopNav, currentUrl: "/docs/troubleshoot/");
+
+ foreach (var listItem in html.Split(" Render(
+ TopNavRenderModel? topNav,
+ string currentUrl,
+ IRootNavigationItem? root = null)
+ {
+ var fileSystem = new MockFileSystem();
+ fileSystem.AddDirectory("/docs");
+ var context = CreateContext(fileSystem);
+
+ var model = new GlobalLayoutViewModel
+ {
+ DocsBuilderVersion = "test",
+ DocSetName = "test",
+ Description = "",
+ CurrentNavigationItem = new StubNavigationItem(currentUrl, root),
+ Previous = null,
+ Next = null,
+ NavigationHtml = "",
+ UrlPathPrefix = "/docs",
+ CanonicalBaseUrl = null,
+ AllowIndexing = false,
+ Features = new FeatureFlags([]),
+ GoogleTagManager = new GoogleTagManagerConfiguration(),
+ Optimizely = new OptimizelyConfiguration(),
+ StaticFileContentHashProvider = new StaticFileContentHashProvider(new EmbeddedOrPhysicalFileProvider(context)),
+ TopNav = topNav
+ };
+
+ return await _SecondaryNav.Create(model).RenderAsync(cancellationToken: TestContext.Current.CancellationToken);
+ }
+
+ /// The secondary nav only reads off the current page.
+ private sealed record StubNavigationItem(
+ string Url,
+ IRootNavigationItem? Root = null) : INavigationItem
+ {
+ public string NavigationTitle => "stub";
+ public IRootNavigationItem NavigationRoot => Root ?? null!;
+ public INodeNavigationItem? Parent { get; set; }
+ public bool Hidden => false;
+ public int NavigationIndex { get; set; }
+ }
+
+ ///
+ /// Minimal root stub — _SecondaryNav.cshtml reads
+ /// and compares its Id against each tab's SectionId(s).
+ ///
+ private sealed class MockSectionRoot(string id)
+ : IRootNavigationItem
+ {
+ public string Id => id;
+ public Uri Identifier => new($"section://{id}");
+ public ILeafNavigationItem Index => null!;
+ public IReadOnlyCollection NavigationItems => [];
+ public string Url => $"/{id}/";
+ public string NavigationTitle => id;
+ public IRootNavigationItem NavigationRoot => this;
+ public INodeNavigationItem? Parent { get; set; }
+ public bool Hidden => false;
+ public int NavigationIndex { get; set; }
+ public bool IsUsingNavigationDropdown => false;
+ public void SetNavigationItems(IReadOnlyCollection navigationItems) { }
+ }
+}