diff --git a/config/navigation.yml b/config/navigation.yml
index 14b502d12d..a636d3df27 100644
--- a/config/navigation.yml
+++ b/config/navigation.yml
@@ -3,6 +3,40 @@
# and content sources for the documentation site.
#############################################
+# The horizontal bar under the global Elastic header.
+#
+# Each entry needs a `title` plus one of:
+# url: a site relative path (/reference/) or an absolute http(s) URL,
+# which renders as an external link with an icon
+# page: a cross link to a page (docs-content://products/elasticsearch/v9.md),
+# resolved to its published URL at assemble time
+# children: renders a dropdown instead of a link. A child that itself has
+# children becomes a group heading inside the panel:
+#
+# - title: Products
+# children:
+# - title: Stack products
+# children:
+# - title: Elasticsearch
+# page: docs-content://products/elasticsearch/v9.md
+# - title: All products # childless entries are listed without a heading
+# url: /products/
+#
+# Remove this key entirely to fall back to the links built into the layout.
+top_nav:
+ # `/` is the docs home. It prefix-matches every page, so Guides is the
+ # highlighted entry on any page no later entry claims.
+ - title: Guides
+ url: /
+ - title: APIs
+ url: https://www.elastic.co/docs/api
+ - title: Release notes
+ url: /release-notes/
+ - title: Troubleshoot
+ url: /troubleshoot/
+ - title: Reference
+ url: /reference/
+
# Use sparingly, makes assembler aware of toc container folders
# That are not linked in the global toc but all the children toc they define are.
phantoms:
diff --git a/src/Elastic.ApiExplorer/Infrastructure/ApiViewModel.cs b/src/Elastic.ApiExplorer/Infrastructure/ApiViewModel.cs
index 5aad0d56ec..1ed446a074 100644
--- a/src/Elastic.ApiExplorer/Infrastructure/ApiViewModel.cs
+++ b/src/Elastic.ApiExplorer/Infrastructure/ApiViewModel.cs
@@ -79,6 +79,7 @@ public ApiLayoutViewModel CreateGlobalLayoutModel()
Features = new FeatureFlags([]),
StaticFileContentHashProvider = StaticFileContentHashProvider,
BuildType = BuildContext.BuildType,
+ TopNav = BuildContext.TopNav,
TocItems = GetTocItems(),
// Header properties for isolated mode
HeaderTitle = docTitle,
diff --git a/src/Elastic.Documentation.Configuration/BuildContext.cs b/src/Elastic.Documentation.Configuration/BuildContext.cs
index 1d0a285bda..618ade6860 100644
--- a/src/Elastic.Documentation.Configuration/BuildContext.cs
+++ b/src/Elastic.Documentation.Configuration/BuildContext.cs
@@ -34,6 +34,12 @@ public record BuildContext : IDocumentationSetContext, IDocumentationConfigurati
public DocumentationSetFile ConfigurationYaml { get; set; }
+ ///
+ /// The resolved site-wide top navigation. Only assembler builds set this; when null the layout
+ /// falls back to its built-in links.
+ ///
+ public TopNavRenderModel? TopNav { get; set; }
+
public VersionsConfiguration VersionsConfiguration { get; }
public ConfigurationFileProvider ConfigurationFileProvider { get; }
public DocumentationEndpoints Endpoints { get; }
diff --git a/src/Elastic.Documentation.Configuration/ConfigurationFileProvider.cs b/src/Elastic.Documentation.Configuration/ConfigurationFileProvider.cs
index 45c7b676b5..aa5c7df7c8 100644
--- a/src/Elastic.Documentation.Configuration/ConfigurationFileProvider.cs
+++ b/src/Elastic.Documentation.Configuration/ConfigurationFileProvider.cs
@@ -28,6 +28,8 @@ public partial class ConfigurationFileProvider
.WithTypeConverter(new TocItemYamlConverter())
.WithTypeConverter(new SiteTableOfContentsCollectionYamlConverter())
.WithTypeConverter(new SiteTableOfContentsRefYamlConverter())
+ .WithTypeConverter(new TopNavItemCollectionYamlConverter())
+ .WithTypeConverter(new TopNavItemConfigYamlConverter())
.WithTypeConverter(new ApiConfigurationConverter())
.Build();
diff --git a/src/Elastic.Documentation.Configuration/Toc/SiteNavigationFile.cs b/src/Elastic.Documentation.Configuration/Toc/SiteNavigationFile.cs
index 409ac897be..a0dd595265 100644
--- a/src/Elastic.Documentation.Configuration/Toc/SiteNavigationFile.cs
+++ b/src/Elastic.Documentation.Configuration/Toc/SiteNavigationFile.cs
@@ -27,6 +27,9 @@ public class SiteNavigationFile
[YamlMember(Alias = "toc")]
public SiteTableOfContents TableOfContents { get; set; } = [];
+ [YamlMember(Alias = "top_nav")]
+ public TopNavItemCollection TopNav { get; set; } = [];
+
public static SiteNavigationFile Deserialize(string yaml) =>
ConfigurationFileProvider.Deserializer.Deserialize(yaml);
diff --git a/src/Elastic.Documentation.Configuration/Toc/TopNavigation.cs b/src/Elastic.Documentation.Configuration/Toc/TopNavigation.cs
new file mode 100644
index 0000000000..3676e52490
--- /dev/null
+++ b/src/Elastic.Documentation.Configuration/Toc/TopNavigation.cs
@@ -0,0 +1,192 @@
+// 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 YamlDotNet.Core;
+using YamlDotNet.Core.Events;
+using YamlDotNet.Serialization;
+
+namespace Elastic.Documentation.Configuration.Toc;
+
+///
+/// The top_nav: entries of navigation.yml, as written. One shape is used at every depth:
+/// a top level entry with children renders as a dropdown, a child with children as a group label
+/// inside that dropdown, and anything carrying url or page as a link.
+///
+public class TopNavItemCollection : List;
+
+public record TopNavItemConfig
+{
+ public string? Title { get; init; }
+
+ /// A site relative path (/reference/) or an absolute http(s) URL.
+ public string? Url { get; init; }
+
+ /// A cross link URI (docs-content://products/elasticsearch/v9.md) resolved at assemble time.
+ public Uri? Page { get; init; }
+
+ public IReadOnlyList Children { get; init; } = [];
+}
+
+///
+/// The resolved top navigation handed to the layout. Every URL here is final: cross links are
+/// resolved and the environment path prefix is already applied, so templates render hrefs as is.
+///
+public record TopNavRenderModel(IReadOnlyList Items)
+{
+ ///
+ /// The href of the entry that best covers , or null when none does.
+ /// Matching is on whole path segments, so /reference/ does not claim /references/x,
+ /// and the longest match wins so a nested entry beats its parent.
+ ///
+ public string? ActiveUrl(string? currentUrl)
+ {
+ if (string.IsNullOrEmpty(currentUrl))
+ return null;
+
+ var current = WithTrailingSlash(currentUrl);
+ string? best = null;
+
+ foreach (var link in Items.SelectMany(EnumerateLinks))
+ {
+ if (link.IsExternal)
+ continue;
+ var candidate = WithTrailingSlash(link.Url);
+ if (!current.StartsWith(candidate, StringComparison.OrdinalIgnoreCase))
+ continue;
+ if (best is null || candidate.Length > best.Length)
+ best = candidate;
+ }
+
+ return best;
+ }
+
+ private static IEnumerable EnumerateLinks(TopNavRenderItem item) => item switch
+ {
+ TopNavLinkItem link => [link],
+ TopNavDropdownItem dropdown => dropdown.Groups.SelectMany(g => g.Links),
+ _ => []
+ };
+
+ internal static string WithTrailingSlash(string url)
+ {
+ var path = url.Split('#')[0];
+ return path.EndsWith('/') ? path : path + '/';
+ }
+}
+
+public abstract record TopNavRenderItem(string Title)
+{
+ /// Whether this entry owns , as returned by .
+ public abstract bool IsActive(string? activeUrl);
+}
+
+public record TopNavLinkItem(string Title, string Url, bool IsExternal) : TopNavRenderItem(Title)
+{
+ public override bool IsActive(string? activeUrl) =>
+ !IsExternal && activeUrl is not null && TopNavRenderModel.WithTrailingSlash(Url) == activeUrl;
+}
+
+public record TopNavDropdownItem(string Title, IReadOnlyList Groups) : TopNavRenderItem(Title)
+{
+ public override bool IsActive(string? activeUrl) =>
+ Groups.SelectMany(g => g.Links).Any(l => l.IsActive(activeUrl));
+}
+
+/// A run of links inside a dropdown. A null means the links are ungrouped.
+public record TopNavGroup(string? Label, IReadOnlyList Links);
+
+public class TopNavItemCollectionYamlConverter : IYamlTypeConverter
+{
+ public bool Accepts(Type type) => type == typeof(TopNavItemCollection);
+
+ public object ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeserializer)
+ {
+ var collection = new TopNavItemCollection();
+
+ if (!parser.TryConsume(out _))
+ return collection;
+
+ while (!parser.TryConsume(out _))
+ {
+ if (rootDeserializer(typeof(TopNavItemConfig)) is TopNavItemConfig item)
+ collection.Add(item);
+ }
+
+ return collection;
+ }
+
+ public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer) =>
+ serializer.Invoke(value, type);
+}
+
+public class TopNavItemConfigYamlConverter : IYamlTypeConverter
+{
+ public bool Accepts(Type type) => type == typeof(TopNavItemConfig);
+
+ public object? ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeserializer)
+ {
+ if (!parser.TryConsume(out _))
+ return null;
+
+ string? title = null;
+ string? url = null;
+ string? page = null;
+ IReadOnlyList children = [];
+
+ while (!parser.TryConsume(out _))
+ {
+ var key = parser.Consume();
+
+ if (parser.Accept(out var scalar))
+ {
+ switch (key.Value)
+ {
+ case "title":
+ title = scalar.Value;
+ break;
+ case "url":
+ url = scalar.Value;
+ break;
+ case "page":
+ page = scalar.Value;
+ break;
+ }
+ _ = parser.MoveNext();
+ }
+ else if (parser.Accept(out _))
+ {
+ if (key.Value == "children")
+ {
+ var list = new List();
+ _ = parser.Consume();
+ while (!parser.TryConsume(out _))
+ {
+ if (rootDeserializer(typeof(TopNavItemConfig)) is TopNavItemConfig child)
+ list.Add(child);
+ }
+ children = list;
+ }
+ else
+ parser.SkipThisAndNestedEvents();
+ }
+ else if (parser.Accept(out _))
+ parser.SkipThisAndNestedEvents();
+ }
+
+ Uri? pageUri = null;
+ if (!string.IsNullOrEmpty(page) && !Uri.TryCreate(page, UriKind.Absolute, out pageUri))
+ throw new InvalidOperationException($"Invalid top_nav page reference: '{page}' could not be parsed as a URI");
+
+ return new TopNavItemConfig
+ {
+ Title = title,
+ Url = url,
+ Page = pageUri,
+ Children = children
+ };
+ }
+
+ public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer) =>
+ serializer.Invoke(value, type);
+}
diff --git a/src/Elastic.Documentation.Site/Assets/main.ts b/src/Elastic.Documentation.Site/Assets/main.ts
index 0843ccc031..8cfb7f371f 100644
--- a/src/Elastic.Documentation.Site/Assets/main.ts
+++ b/src/Elastic.Documentation.Site/Assets/main.ts
@@ -8,6 +8,7 @@ import { initImageCarousel } from './image-carousel'
import { initMermaid } from './mermaid'
import { openDetailsWithAnchor } from './open-details-with-anchor'
import { initNav } from './pages-nav'
+import { initSecondaryNav } from './secondary-nav'
import { initSmoothScroll } from './smooth-scroll'
import { initTable } from './table'
import { initTabs } from './tabs'
@@ -235,6 +236,7 @@ function handleCtaActivation(event: MouseEvent) {
logCtaEvent('cta_clicked', cta)
}
document.addEventListener('click', handleCtaActivation)
+initSecondaryNav()
// 'auxclick' with button 1 covers middle-click (open in new tab), which does NOT
// fire 'click' per the DOM spec - without this those opens went untracked. Button 2
// (right-click / context menu) also fires auxclick but isn't a real engagement.
diff --git a/src/Elastic.Documentation.Site/Assets/secondary-nav-dropdown.css b/src/Elastic.Documentation.Site/Assets/secondary-nav-dropdown.css
new file mode 100644
index 0000000000..a782b9accb
--- /dev/null
+++ b/src/Elastic.Documentation.Site/Assets/secondary-nav-dropdown.css
@@ -0,0 +1,87 @@
+/*
+ * Top-bar dropdown. Rendered for a `top_nav:` entry in navigation.yml that has
+ * `children:`. The label is a pure toggle, not a link: the panel lists the
+ * entry's children so users can jump straight to a child page.
+ *
+ * Open/close is native . Closing on outside click or Escape is not,
+ * so that lives in secondary-nav.ts.
+ */
+
+@layer components {
+ .secondary-nav-dropdown {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ }
+
+ /* The nav bar uses overflow-x:auto for horizontal tab scrolling, which
+ also clips vertically and would cut off an open dropdown. Let the menu
+ escape only while a dropdown is open. */
+ .secondary-nav-scroll-container:has(.secondary-nav-dropdown[open]) {
+ /* !important to win over the Tailwind `overflow-x-auto` utility,
+ which sits in a higher cascade layer. */
+ overflow: visible !important;
+ }
+
+ .secondary-nav-dropdown summary {
+ list-style: none;
+ }
+ .secondary-nav-dropdown summary::-webkit-details-marker {
+ display: none;
+ }
+
+ .secondary-nav-dropdown-chevron {
+ flex-shrink: 0;
+ color: var(--color-grey-60);
+ transition: transform 0.15s ease;
+ }
+ .secondary-nav-dropdown[open] .secondary-nav-dropdown-chevron {
+ transform: rotate(180deg);
+ }
+
+ .secondary-nav-dropdown-menu {
+ position: absolute;
+ top: calc(100% + 4px);
+ left: 0;
+ min-width: 220px;
+ max-width: 320px;
+ z-index: 50;
+ display: none;
+ flex-direction: column;
+ padding: 6px 0;
+ background: var(--color-white);
+ border: 1px solid var(--color-grey-20);
+ border-radius: 6px;
+ box-shadow: 0 8px 24px rgb(0 0 0 / 0.08);
+ font-weight: 500;
+ }
+ .secondary-nav-dropdown[open] .secondary-nav-dropdown-menu {
+ display: flex;
+ }
+
+ .secondary-nav-dropdown-group-label {
+ padding: 8px 14px 4px 14px;
+ font-size: 12px;
+ font-weight: 700;
+ color: var(--color-grey-80);
+ user-select: none;
+ }
+ .secondary-nav-dropdown-group-label:not(:first-child) {
+ margin-top: 4px;
+ border-top: 1px solid var(--color-grey-15, var(--color-grey-20));
+ padding-top: 10px;
+ }
+
+ .secondary-nav-dropdown-link {
+ display: block;
+ padding: 6px 14px;
+ font-size: 14px;
+ color: var(--color-ink-dark);
+ text-decoration: none;
+ transition: background-color 0.12s ease;
+ }
+ .secondary-nav-dropdown-link:hover {
+ background: var(--color-grey-10);
+ color: var(--color-blue-elastic);
+ }
+}
diff --git a/src/Elastic.Documentation.Site/Assets/secondary-nav.test.ts b/src/Elastic.Documentation.Site/Assets/secondary-nav.test.ts
new file mode 100644
index 0000000000..8fe991c986
--- /dev/null
+++ b/src/Elastic.Documentation.Site/Assets/secondary-nav.test.ts
@@ -0,0 +1,73 @@
+import { initSecondaryNav } from './secondary-nav'
+
+function renderNav() {
+ document.body.innerHTML = `
+
+
page
+ `
+ return {
+ products: document.querySelector('#products')!,
+ extend: document.querySelector('#extend')!,
+ outside: document.querySelector('#outside')!,
+ }
+}
+
+describe('initSecondaryNav', () => {
+ beforeAll(() => initSecondaryNav())
+
+ it('closes an open dropdown when clicking outside of it', () => {
+ const { products, outside } = renderNav()
+ products.open = true
+
+ outside.dispatchEvent(new MouseEvent('click', { bubbles: true }))
+
+ expect(products.open).toBe(false)
+ })
+
+ it('closes the other dropdowns when one is opened', () => {
+ const { products, extend } = renderNav()
+ products.open = true
+
+ // the native toggle opens this one, our listener closes the sibling
+ extend
+ .querySelector('summary')!
+ .dispatchEvent(new MouseEvent('click', { bubbles: true }))
+
+ expect(products.open).toBe(false)
+ expect(extend.open).toBe(true)
+ })
+
+ it('leaves clicks inside the open panel alone so links still work', () => {
+ const { products } = renderNav()
+ products.open = true
+
+ products
+ .querySelector('a')!
+ .dispatchEvent(new MouseEvent('click', { bubbles: true }))
+
+ expect(products.open).toBe(true)
+ })
+
+ it('closes on Escape and returns focus to the summary', () => {
+ const { products } = renderNav()
+ products.open = true
+ const summary = products.querySelector('summary')!
+ summary.focus()
+
+ document.dispatchEvent(
+ new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })
+ )
+
+ expect(products.open).toBe(false)
+ expect(document.activeElement).toBe(summary)
+ })
+})
diff --git a/src/Elastic.Documentation.Site/Assets/secondary-nav.ts b/src/Elastic.Documentation.Site/Assets/secondary-nav.ts
new file mode 100644
index 0000000000..a969fdf15b
--- /dev/null
+++ b/src/Elastic.Documentation.Site/Assets/secondary-nav.ts
@@ -0,0 +1,45 @@
+/**
+ * Close behaviour for the top-bar dropdowns.
+ *
+ * Native opens and closes on summary clicks, but it does not close when
+ * the user clicks elsewhere or presses Escape, which for a nav menu leaves a panel
+ * stranded over the page. These are delegated document listeners, so they survive
+ * the htmx body swaps that replace the nav on every navigation.
+ */
+
+const DROPDOWN = 'details.secondary-nav-dropdown'
+
+function openDropdowns(): HTMLDetailsElement[] {
+ return Array.from(
+ document.querySelectorAll(`${DROPDOWN}[open]`)
+ )
+}
+
+function closeAllExcept(keep?: HTMLDetailsElement) {
+ for (const dropdown of openDropdowns()) {
+ if (dropdown !== keep) dropdown.open = false
+ }
+}
+
+export function initSecondaryNav() {
+ document.addEventListener('click', (event: MouseEvent) => {
+ const target = event.target as HTMLElement | null
+ // A click on a summary toggles its own dropdown; only the siblings close here,
+ // otherwise we would fight the native toggle.
+ const clicked =
+ target?.closest(DROPDOWN) ?? undefined
+ closeAllExcept(clicked)
+ })
+
+ document.addEventListener('keydown', (event: KeyboardEvent) => {
+ if (event.key !== 'Escape') return
+ const open = openDropdowns()
+ if (open.length === 0) return
+ const active = (
+ document.activeElement as HTMLElement | null
+ )?.closest(DROPDOWN)
+ closeAllExcept()
+ // Focus would otherwise be lost on the removed panel, stranding keyboard users.
+ active?.querySelector('summary')?.focus()
+ })
+}
diff --git a/src/Elastic.Documentation.Site/Assets/styles.css b/src/Elastic.Documentation.Site/Assets/styles.css
index c3599fa395..d70879b8bd 100644
--- a/src/Elastic.Documentation.Site/Assets/styles.css
+++ b/src/Elastic.Documentation.Site/Assets/styles.css
@@ -22,6 +22,7 @@
@import './markdown/image-carousel.css';
@import './markdown/hr.css';
@import './modal.css';
+@import './secondary-nav-dropdown.css';
@import './archive.css';
@import './markdown/stepper.css';
@import './markdown/button.css';
diff --git a/src/Elastic.Documentation.Site/Layout/_SecondaryNav.cshtml b/src/Elastic.Documentation.Site/Layout/_SecondaryNav.cshtml
index 10ce2f50e9..168dd29488 100644
--- a/src/Elastic.Documentation.Site/Layout/_SecondaryNav.cshtml
+++ b/src/Elastic.Documentation.Site/Layout/_SecondaryNav.cshtml
@@ -1,40 +1,108 @@
+@using Elastic.Documentation.Configuration.Toc
@inherits RazorSlice
+@{
+ var topNav = Model.TopNav;
+ var activeUrl = topNav?.ActiveUrl(Model.CurrentNavigationItem.Url);
+}
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.Markdown/HtmlWriter.cs b/src/Elastic.Markdown/HtmlWriter.cs
index be506878f7..d4c5dc7d49 100644
--- a/src/Elastic.Markdown/HtmlWriter.cs
+++ b/src/Elastic.Markdown/HtmlWriter.cs
@@ -186,6 +186,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 f79b0b1853..4db56ab707 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..5a31144caf 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 })
diff --git a/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildService.cs b/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildService.cs
index 5534fd7c98..b11512b357 100644
--- a/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildService.cs
+++ b/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildService.cs
@@ -115,6 +115,11 @@ Cancel ctx
if (!SiteNavigationFile.ValidatePathPrefixes(assembleContext.Collector, siteNavigationFile, navigationFileInfo) || assembleContext.Collector.Errors > 0)
return false;
+ var topNav = TopNavResolver.Resolve(siteNavigationFile, assembleSources.CrossLinkResolver,
+ assembleContext.Environment.PathPrefix, assembleContext.Collector, navigationFileInfo);
+ 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/TopNavResolver.cs b/src/services/Elastic.Documentation.Assembler/Navigation/TopNavResolver.cs
new file mode 100644
index 0000000000..a5bcc691ad
--- /dev/null
+++ b/src/services/Elastic.Documentation.Assembler/Navigation/TopNavResolver.cs
@@ -0,0 +1,188 @@
+// 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;
+using Elastic.Documentation.Configuration.Toc;
+using Elastic.Documentation.Diagnostics;
+using Elastic.Documentation.Links.CrossLinks;
+
+namespace Elastic.Documentation.Assembler.Navigation;
+
+///
+/// Turns the top_nav: entries of navigation.yml into a with final hrefs.
+/// Runs once per assemble, before any page is rendered.
+///
+public static class TopNavResolver
+{
+ ///
+ /// Resolves the configured top navigation, or returns null when nothing is configured, in which
+ /// case the layout keeps rendering its built-in links.
+ ///
+ public static TopNavRenderModel? Resolve(
+ SiteNavigationFile navigationFile,
+ ICrossLinkResolver crossLinkResolver,
+ string? pathPrefix,
+ IDiagnosticsCollector collector,
+ IFileInfo navigationFileInfo
+ )
+ {
+ if (navigationFile.TopNav.Count == 0)
+ return null;
+
+ var items = new List();
+ foreach (var config in navigationFile.TopNav)
+ {
+ if (ResolveItem(config, crossLinkResolver, pathPrefix, collector, navigationFileInfo) is { } item)
+ items.Add(item);
+ }
+
+ return items.Count == 0 ? null : new TopNavRenderModel(items);
+ }
+
+ private static TopNavRenderItem? ResolveItem(
+ TopNavItemConfig config,
+ ICrossLinkResolver crossLinkResolver,
+ string? pathPrefix,
+ IDiagnosticsCollector collector,
+ IFileInfo navigationFileInfo
+ )
+ {
+ if (string.IsNullOrWhiteSpace(config.Title))
+ {
+ collector.EmitError(navigationFileInfo, "top_nav entry is missing a 'title'");
+ return null;
+ }
+
+ if (config.Children.Count == 0)
+ return ResolveLink(config, config.Title, crossLinkResolver, pathPrefix, collector, navigationFileInfo);
+
+ if (config.Url is not null || config.Page is not null)
+ {
+ collector.EmitWarning(navigationFileInfo,
+ $"top_nav entry '{config.Title}' has children, so its 'url'/'page' is ignored: the label only toggles the dropdown");
+ }
+
+ var groups = ResolveGroups(config, crossLinkResolver, pathPrefix, collector, navigationFileInfo);
+ if (groups.Count == 0)
+ {
+ collector.EmitWarning(navigationFileInfo, $"top_nav dropdown '{config.Title}' has no resolvable links and is not rendered");
+ return null;
+ }
+
+ return new TopNavDropdownItem(config.Title, groups);
+ }
+
+ ///
+ /// Flattens a dropdown's children into groups. A child with children of its own becomes a labelled
+ /// group; consecutive childless children are collected into a single unlabelled group.
+ ///
+ private static List ResolveGroups(
+ TopNavItemConfig dropdown,
+ ICrossLinkResolver crossLinkResolver,
+ string? pathPrefix,
+ IDiagnosticsCollector collector,
+ IFileInfo navigationFileInfo
+ )
+ {
+ var groups = new List();
+ var ungrouped = new List();
+
+ foreach (var child in dropdown.Children)
+ {
+ if (string.IsNullOrWhiteSpace(child.Title))
+ {
+ collector.EmitError(navigationFileInfo, $"top_nav entry under '{dropdown.Title}' is missing a 'title'");
+ continue;
+ }
+
+ if (child.Children.Count == 0)
+ {
+ if (ResolveLink(child, child.Title, crossLinkResolver, pathPrefix, collector, navigationFileInfo) is { } link)
+ ungrouped.Add(link);
+ continue;
+ }
+
+ if (ungrouped.Count > 0)
+ {
+ groups.Add(new TopNavGroup(null, ungrouped.ToArray()));
+ ungrouped.Clear();
+ }
+
+ var links = new List();
+ foreach (var grandChild in child.Children)
+ {
+ if (string.IsNullOrWhiteSpace(grandChild.Title))
+ {
+ collector.EmitError(navigationFileInfo, $"top_nav entry under '{child.Title}' is missing a 'title'");
+ continue;
+ }
+
+ if (grandChild.Children.Count > 0)
+ {
+ collector.EmitError(navigationFileInfo,
+ $"top_nav entry '{grandChild.Title}' nests too deeply: a dropdown supports one level of groups only");
+ continue;
+ }
+
+ if (ResolveLink(grandChild, grandChild.Title, crossLinkResolver, pathPrefix, collector, navigationFileInfo) is { } link)
+ links.Add(link);
+ }
+
+ if (links.Count > 0)
+ groups.Add(new TopNavGroup(child.Title, links));
+ else
+ collector.EmitWarning(navigationFileInfo, $"top_nav group '{child.Title}' has no resolvable links and is not rendered");
+ }
+
+ if (ungrouped.Count > 0)
+ groups.Add(new TopNavGroup(null, ungrouped.ToArray()));
+
+ return groups;
+ }
+
+ private static TopNavLinkItem? ResolveLink(
+ TopNavItemConfig config,
+ string title,
+ ICrossLinkResolver crossLinkResolver,
+ string? pathPrefix,
+ IDiagnosticsCollector collector,
+ IFileInfo navigationFileInfo
+ )
+ {
+ if (config.Url is not null && config.Page is not null)
+ {
+ collector.EmitError(navigationFileInfo, $"top_nav entry '{title}' sets both 'url' and 'page', use one of them");
+ return null;
+ }
+
+ if (config.Page is { } page)
+ {
+ var errors = new List();
+ if (!crossLinkResolver.TryResolve(errors.Add, page, out var resolved))
+ {
+ collector.EmitError(navigationFileInfo,
+ $"top_nav entry '{title}' could not resolve page '{page}': {string.Join("; ", errors)}");
+ return null;
+ }
+
+ return new TopNavLinkItem(title, EnsureTrailingSlash(resolved.AbsolutePath), false);
+ }
+
+ if (string.IsNullOrWhiteSpace(config.Url))
+ {
+ collector.EmitError(navigationFileInfo, $"top_nav entry '{title}' needs a 'url', a 'page' or 'children'");
+ return null;
+ }
+
+ if (config.Url.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
+ || config.Url.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
+ return new TopNavLinkItem(title, config.Url, true);
+
+ var prefix = string.IsNullOrWhiteSpace(pathPrefix) ? string.Empty : $"/{pathPrefix.Trim('/')}";
+ return new TopNavLinkItem(title, EnsureTrailingSlash($"{prefix}/{config.Url.TrimStart('/')}"), false);
+ }
+
+ private static string EnsureTrailingSlash(string url) =>
+ url.Contains('#') || url.EndsWith('/') ? url : url + '/';
+}
diff --git a/tests/Elastic.Documentation.Build.Tests/TopNavResolverTests.cs b/tests/Elastic.Documentation.Build.Tests/TopNavResolverTests.cs
new file mode 100644
index 0000000000..3beddd88c1
--- /dev/null
+++ b/tests/Elastic.Documentation.Build.Tests/TopNavResolverTests.cs
@@ -0,0 +1,258 @@
+// 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.Collections.Frozen;
+using System.IO.Abstractions;
+using System.IO.Abstractions.TestingHelpers;
+using AwesomeAssertions;
+using Elastic.Documentation;
+using Elastic.Documentation.Assembler.Links;
+using Elastic.Documentation.Assembler.Navigation;
+using Elastic.Documentation.Configuration.Assembler;
+using Elastic.Documentation.Configuration.Toc;
+using Elastic.Documentation.Diagnostics;
+using Elastic.Documentation.Links;
+using Elastic.Documentation.Links.CrossLinks;
+
+namespace Elastic.Documentation.Build.Tests;
+
+public class TopNavResolverTests
+{
+ private static readonly IFileInfo NavigationFile =
+ new MockFileSystem().FileInfo.New("/config/navigation.yml");
+
+ [Fact]
+ public void RelativeUrlGainsThePathPrefix()
+ {
+ var model = Resolve("""
+ top_nav:
+ - title: Reference
+ url: /reference/
+ """, out var collector);
+
+ collector.Errors.Should().Be(0);
+ model.Should().NotBeNull();
+ var link = model.Items.Should().ContainSingle().Which.Should().BeOfType().Subject;
+ link.Title.Should().Be("Reference");
+ link.Url.Should().Be("/docs/reference/");
+ link.IsExternal.Should().BeFalse();
+ }
+
+ [Fact]
+ public void AbsoluteUrlIsMarkedExternalAndLeftUntouched()
+ {
+ var model = Resolve("""
+ top_nav:
+ - title: APIs
+ url: https://www.elastic.co/docs/api/
+ """, out var collector);
+
+ collector.Errors.Should().Be(0);
+ var link = model!.Items.Should().ContainSingle().Which.Should().BeOfType().Subject;
+ link.IsExternal.Should().BeTrue();
+ link.Url.Should().Be("https://www.elastic.co/docs/api/");
+ }
+
+ [Fact]
+ public void PageReferenceResolvesThroughTheCrossLinkIndex()
+ {
+ var model = Resolve("""
+ top_nav:
+ - title: Products
+ children:
+ - title: Stack products
+ children:
+ - title: Elasticsearch
+ page: docs-content://products/elasticsearch.md
+ """, out var collector);
+
+ collector.Errors.Should().Be(0);
+ var dropdown = model!.Items.Should().ContainSingle().Which.Should().BeOfType().Subject;
+ dropdown.Title.Should().Be("Products");
+ var group = dropdown.Groups.Should().ContainSingle().Which;
+ group.Label.Should().Be("Stack products");
+ group.Links.Should().ContainSingle().Which.Url.Should().Be("/docs/products/elasticsearch/");
+ }
+
+ [Fact]
+ public void ChildlessChildrenAreCollectedIntoAnUnlabelledGroup()
+ {
+ var model = Resolve("""
+ top_nav:
+ - title: Products
+ children:
+ - title: All products
+ url: /products/
+ """, out var collector);
+
+ collector.Errors.Should().Be(0);
+ var dropdown = model!.Items.Should().ContainSingle().Which.Should().BeOfType().Subject;
+ var group = dropdown.Groups.Should().ContainSingle().Which;
+ group.Label.Should().BeNull();
+ group.Links.Should().ContainSingle().Which.Url.Should().Be("/docs/products/");
+ }
+
+ [Fact]
+ public void UnresolvablePageEmitsAnErrorAndDropsTheEntry()
+ {
+ var model = Resolve("""
+ top_nav:
+ - title: Missing
+ page: docs-content://does/not/exist.md
+ - title: Reference
+ url: /reference/
+ """, out var collector);
+
+ collector.Errors.Should().Be(1);
+ model!.Items.Should().ContainSingle().Which.Title.Should().Be("Reference");
+ }
+
+ [Fact]
+ public void SettingBothUrlAndPageIsAnError()
+ {
+ var model = Resolve("""
+ top_nav:
+ - title: Confused
+ url: /reference/
+ page: docs-content://products/elasticsearch.md
+ """, out var collector);
+
+ collector.Errors.Should().Be(1);
+ model.Should().BeNull();
+ }
+
+ [Fact]
+ public void EntryWithoutUrlPageOrChildrenIsAnError()
+ {
+ _ = Resolve("""
+ top_nav:
+ - title: Dangling
+ """, out var collector);
+
+ collector.Errors.Should().Be(1);
+ }
+
+ [Fact]
+ public void NestingBeyondOneGroupLevelIsAnError()
+ {
+ _ = Resolve("""
+ top_nav:
+ - title: Products
+ children:
+ - title: Stack products
+ children:
+ - title: Too deep
+ children:
+ - title: Way too deep
+ url: /x/
+ """, out var collector);
+
+ collector.Errors.Should().Be(1);
+ }
+
+ [Fact]
+ public void AbsentTopNavResolvesToNull()
+ {
+ var model = Resolve("""
+ toc:
+ - toc: docs-content://products
+ path_prefix: products
+ """, out var collector);
+
+ collector.Errors.Should().Be(0);
+ model.Should().BeNull();
+ }
+
+ [Fact]
+ public void ActiveUrlPrefersTheLongestWholeSegmentMatch()
+ {
+ var model = Resolve("""
+ top_nav:
+ - title: Reference
+ url: /reference/
+ - title: Reference APIs
+ url: /reference/apis/
+ - title: External
+ url: https://example.com/reference/apis/
+ """, out _);
+
+ model!.ActiveUrl("/docs/reference/apis/search").Should().Be("/docs/reference/apis/");
+ model.ActiveUrl("/docs/reference/other").Should().Be("/docs/reference/");
+ // a sibling path that merely shares a textual prefix must not match
+ model.ActiveUrl("/docs/references/other").Should().BeNull();
+ model.ActiveUrl(null).Should().BeNull();
+ }
+
+ [Fact]
+ public void RootUrlResolvesToThePrefixAndActsAsTheCatchAllEntry()
+ {
+ var model = Resolve("""
+ top_nav:
+ - title: Guides
+ url: /
+ - title: Reference
+ url: /reference/
+ """, out var collector);
+
+ collector.Errors.Should().Be(0);
+ var guides = model!.Items[0].Should().BeOfType().Subject;
+ guides.Url.Should().Be("/docs/");
+
+ // anything not claimed by a more specific entry falls to Guides
+ model.ActiveUrl("/docs/solutions/search").Should().Be("/docs/");
+ model.ActiveUrl("/docs/").Should().Be("/docs/");
+ // but a more specific entry still wins
+ model.ActiveUrl("/docs/reference/apis").Should().Be("/docs/reference/");
+ }
+
+ private static TopNavRenderModel? Resolve(string yaml, out DiagnosticsCollector collector)
+ {
+ collector = new TestDiagnosticsCollector();
+ _ = collector.StartAsync(TestContext.Current.CancellationToken);
+
+ var navigationFile = SiteNavigationFile.Deserialize(yaml);
+ var environment = new PublishEnvironment
+ {
+ Name = "test",
+ Uri = "https://www.elastic.co",
+ PathPrefix = "docs"
+ };
+ var mappings = new Dictionary
+ {
+ [new Uri("docs-content://products")] = new()
+ {
+ Source = new Uri("docs-content://products"),
+ SourcePathPrefix = "products"
+ }
+ }.ToFrozenDictionary();
+
+ var crossLinks = new FetchedCrossLinks
+ {
+ DeclaredRepositories = ["docs-content"],
+ LinkIndexEntries = FrozenDictionary.Empty,
+ LinkReferences = new Dictionary
+ {
+ ["docs-content"] = new()
+ {
+ Origin = GitCheckoutInformation.Unavailable,
+ UrlPathPrefix = null,
+ CrossLinks = [],
+ Links = new Dictionary
+ {
+ ["products/elasticsearch.md"] = new()
+ {
+ Anchors = null,
+ Hidden = false
+ }
+ }
+ }
+ }.ToFrozenDictionary()
+ };
+
+ var resolver = new CrossLinkResolver(crossLinks, new PublishEnvironmentUriResolver(mappings, environment));
+ return TopNavResolver.Resolve(navigationFile, resolver, environment.PathPrefix, collector, NavigationFile);
+ }
+
+ private sealed class TestDiagnosticsCollector() : DiagnosticsCollector([]);
+}
diff --git a/tests/Elastic.Documentation.Configuration.Tests/SiteNavigationFileTests.cs b/tests/Elastic.Documentation.Configuration.Tests/SiteNavigationFileTests.cs
index 95fd3c2cf5..dc5976d600 100644
--- a/tests/Elastic.Documentation.Configuration.Tests/SiteNavigationFileTests.cs
+++ b/tests/Elastic.Documentation.Configuration.Tests/SiteNavigationFileTests.cs
@@ -142,4 +142,121 @@ public void ThrowsExceptionForInvalidUri()
.WithInnerException()
.WithMessage("Invalid TOC source: '://invalid' could not be parsed as a URI");
}
+
+ [Fact]
+ public void DeserializesTopNavLinks()
+ {
+ // language=yaml
+ var yaml = """
+ top_nav:
+ - title: Release notes
+ url: /release-notes/
+ - title: APIs
+ url: https://www.elastic.co/docs/api/
+ toc:
+ - toc: serverless/search
+ path_prefix: /serverless/search
+ """;
+
+ var siteNav = SiteNavigationFile.Deserialize(yaml);
+
+ siteNav.TopNav.Should().HaveCount(2);
+ siteNav.TopNav[0].Title.Should().Be("Release notes");
+ siteNav.TopNav[0].Url.Should().Be("/release-notes/");
+ siteNav.TopNav[0].Page.Should().BeNull();
+ siteNav.TopNav[0].Children.Should().BeEmpty();
+ siteNav.TopNav[1].Url.Should().Be("https://www.elastic.co/docs/api/");
+
+ // the new key must not disturb the existing ones
+ siteNav.TableOfContents.Should().HaveCount(1);
+ }
+
+ [Fact]
+ public void DeserializesTopNavDropdownWithGroupsAndPageReferences()
+ {
+ // language=yaml
+ var yaml = """
+ top_nav:
+ - title: Products
+ children:
+ - title: Stack products
+ children:
+ - title: Elasticsearch
+ page: docs-content://products/elasticsearch/v9.md
+ - title: Kibana
+ page: docs-content://products/kibana/v9.md
+ - title: All products
+ url: /products/
+ """;
+
+ var siteNav = SiteNavigationFile.Deserialize(yaml);
+
+ siteNav.TopNav.Should().HaveCount(1);
+ var products = siteNav.TopNav[0];
+ products.Title.Should().Be("Products");
+ products.Children.Should().HaveCount(2);
+
+ var group = products.Children[0];
+ group.Title.Should().Be("Stack products");
+ group.Children.Should().HaveCount(2);
+ group.Children[0].Title.Should().Be("Elasticsearch");
+ group.Children[0].Page.Should().Be(new Uri("docs-content://products/elasticsearch/v9.md"));
+
+ var ungrouped = products.Children[1];
+ ungrouped.Url.Should().Be("/products/");
+ ungrouped.Children.Should().BeEmpty();
+ }
+
+ [Fact]
+ public void TopNavDefaultsToEmptyWhenAbsent()
+ {
+ // language=yaml
+ var yaml = """
+ toc:
+ - toc: serverless/search
+ path_prefix: /serverless/search
+ """;
+
+ SiteNavigationFile.Deserialize(yaml).TopNav.Should().BeEmpty();
+ }
+
+ ///
+ /// The shipped config/navigation.yml drives the top nav on every assembled page,
+ /// so a typo there breaks the whole site rather than one doc.
+ ///
+ [Fact]
+ public void ShippedNavigationYmlHasAUsableTopNav()
+ {
+ 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.TopNav.Should().NotBeEmpty();
+ foreach (var item in siteNav.TopNav)
+ {
+ item.Title.Should().NotBeNullOrWhiteSpace();
+ var hasTarget = item.Url is not null || item.Page is not null || item.Children.Count > 0;
+ hasTarget.Should().BeTrue($"top_nav entry '{item.Title}' needs a url, page or children");
+ (item.Url is not null && item.Page is not null).Should().BeFalse($"top_nav entry '{item.Title}' sets both url and page");
+ }
+ }
+
+ [Fact]
+ public void ThrowsExceptionForInvalidTopNavPageReference()
+ {
+ // language=yaml
+ var yaml = """
+ top_nav:
+ - title: Broken
+ page: ://invalid
+ """;
+
+ var act = () => SiteNavigationFile.Deserialize(yaml);
+
+ act.Should().Throw()
+ .WithInnerException()
+ .WithMessage("Invalid top_nav page reference: '://invalid' could not be parsed as a URI");
+ }
}
diff --git a/tests/Navigation.Tests/Rendering/SecondaryNavRenderingTests.cs b/tests/Navigation.Tests/Rendering/SecondaryNavRenderingTests.cs
new file mode 100644
index 0000000000..0717ae1a25
--- /dev/null
+++ b/tests/Navigation.Tests/Rendering/SecondaryNavRenderingTests.cs
@@ -0,0 +1,148 @@
+// 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 static readonly TopNavRenderModel TopNav = new([
+ new TopNavLinkItem("Reference", "/docs/reference/", false),
+ 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");
+
+ // a page under a dropdown child highlights the dropdown label
+ var product = await Render(TopNav, currentUrl: "/docs/products/elasticsearch/index");
+ var productListItem = product.Split("