From 13f78d20264daf640f9b48c641daca86bc922a43 Mon Sep 17 00:00:00 2001 From: Fabrizio Ferri Benedetti Date: Thu, 6 Aug 2026 13:39:22 +0200 Subject: [PATCH 1/4] Navigation: make the top nav configurable in navigation.yml The bar under the global Elastic header was a hardcoded list of three links in _SecondaryNav.cshtml, so changing it meant changing a template. Add a `top_nav:` key to navigation.yml. Each entry is a link, an external link, or a dropdown with grouped children. Links target either a site relative `url:` or a cross-repo `page:` resolved at assemble time through the existing CrossLinkResolver. The resolved model is stamped onto every set's BuildContext once per assemble and reaches the layout from there. Only assembler builds set it; isolated and codex builds leave it null and keep rendering the built-in links, as does an assemble whose navigation.yml has no `top_nav:`. The shipped default reproduces the current three links, so the rendered header does not change on merge. Dropdowns are native
, which does not close on outside click or Escape; secondary-nav.ts adds that with delegated listeners so the behaviour survives htmx body swaps. Co-Authored-By: Claude Opus 5 (1M context) --- config/navigation.yml | 28 +++ .../Infrastructure/ApiViewModel.cs | 1 + .../BuildContext.cs | 6 + .../ConfigurationFileProvider.cs | 2 + .../Toc/SiteNavigationFile.cs | 3 + .../Toc/TopNavigation.cs | 192 ++++++++++++++ src/Elastic.Documentation.Site/Assets/main.ts | 2 + .../Assets/secondary-nav-dropdown.css | 87 +++++++ .../Assets/secondary-nav.test.ts | 73 ++++++ .../Assets/secondary-nav.ts | 44 ++++ .../Assets/styles.css | 1 + .../Layout/_SecondaryNav.cshtml | 122 +++++++-- src/Elastic.Documentation.Site/_ViewModels.cs | 6 + src/Elastic.Markdown/HtmlWriter.cs | 1 + src/Elastic.Markdown/Page/Index.cshtml | 1 + src/Elastic.Markdown/Page/IndexViewModel.cs | 3 + .../AssembleSources.cs | 3 + .../Building/AssemblerBuildService.cs | 5 + .../Navigation/TopNavResolver.cs | 188 ++++++++++++++ .../TopNavResolverTests.cs | 236 ++++++++++++++++++ .../SiteNavigationFileTests.cs | 94 +++++++ .../Rendering/SecondaryNavRenderingTests.cs | 138 ++++++++++ 22 files changed, 1211 insertions(+), 25 deletions(-) create mode 100644 src/Elastic.Documentation.Configuration/Toc/TopNavigation.cs create mode 100644 src/Elastic.Documentation.Site/Assets/secondary-nav-dropdown.css create mode 100644 src/Elastic.Documentation.Site/Assets/secondary-nav.test.ts create mode 100644 src/Elastic.Documentation.Site/Assets/secondary-nav.ts create mode 100644 src/services/Elastic.Documentation.Assembler/Navigation/TopNavResolver.cs create mode 100644 tests/Elastic.Documentation.Build.Tests/TopNavResolverTests.cs create mode 100644 tests/Navigation.Tests/Rendering/SecondaryNavRenderingTests.cs diff --git a/config/navigation.yml b/config/navigation.yml index 14b502d12d..f0a0bf77ac 100644 --- a/config/navigation.yml +++ b/config/navigation.yml @@ -3,6 +3,34 @@ # 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: + - 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..78877f33c8 --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/secondary-nav.ts @@ -0,0 +1,44 @@ +/** + * 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..27ff43d5ac 100644 --- a/src/Elastic.Documentation.Site/Layout/_SecondaryNav.cshtml +++ b/src/Elastic.Documentation.Site/Layout/_SecondaryNav.cshtml @@ -1,9 +1,17 @@ +@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..189a5246cf --- /dev/null +++ b/tests/Elastic.Documentation.Build.Tests/TopNavResolverTests.cs @@ -0,0 +1,236 @@ +// 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(); + } + + 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..591ae30ae0 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/SiteNavigationFileTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/SiteNavigationFileTests.cs @@ -142,4 +142,98 @@ 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(); + } + + [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..8bfc1712ce --- /dev/null +++ b/tests/Navigation.Tests/Rendering/SecondaryNavRenderingTests.cs @@ -0,0 +1,138 @@ +// 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 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(" li.Contains("Products")); + productListItem.Should().Contain("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) + { + var fileSystem = new MockFileSystem(); + fileSystem.AddDirectory("/docs"); + var context = CreateContext(fileSystem); + + var model = new GlobalLayoutViewModel + { + DocsBuilderVersion = "test", + DocSetName = "test", + Description = "", + CurrentNavigationItem = new StubNavigationItem(currentUrl), + 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) : INavigationItem + { + public string NavigationTitle => "stub"; + public IRootNavigationItem NavigationRoot => null!; + public INodeNavigationItem? Parent { get; set; } + public bool Hidden => false; + public int NavigationIndex { get; set; } + } +} From 74fac357e76235710b42c89d24b096af8d1535ee Mon Sep 17 00:00:00 2001 From: Fabrizio Ferri Benedetti Date: Thu, 6 Aug 2026 13:43:47 +0200 Subject: [PATCH 2/4] Navigation: left align the top nav and drop the "Docs" link The bar put a static "Docs" link on the left and pushed the items to the right edge. Align the items to the left instead and remove the link, so the bar reads as one row of navigation rather than a brand plus a menu. Co-Authored-By: Claude Opus 5 (1M context) --- .../Layout/_SecondaryNav.cshtml | 6 +----- .../Rendering/SecondaryNavRenderingTests.cs | 10 ++++++++++ 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/Elastic.Documentation.Site/Layout/_SecondaryNav.cshtml b/src/Elastic.Documentation.Site/Layout/_SecondaryNav.cshtml index 27ff43d5ac..168dd29488 100644 --- a/src/Elastic.Documentation.Site/Layout/_SecondaryNav.cshtml +++ b/src/Elastic.Documentation.Site/Layout/_SecondaryNav.cshtml @@ -12,12 +12,8 @@
-
- Docs -
    @if (topNav is not null) { diff --git a/tests/Navigation.Tests/Rendering/SecondaryNavRenderingTests.cs b/tests/Navigation.Tests/Rendering/SecondaryNavRenderingTests.cs index 8bfc1712ce..0717ae1a25 100644 --- a/tests/Navigation.Tests/Rendering/SecondaryNavRenderingTests.cs +++ b/tests/Navigation.Tests/Rendering/SecondaryNavRenderingTests.cs @@ -52,6 +52,16 @@ public async Task ConfiguredLinksReplaceTheBuiltInOnes() 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() { From 900c50296a7e43a939e90b83da0067b989c4a339 Mon Sep 17 00:00:00 2001 From: Fabrizio Ferri Benedetti Date: Thu, 6 Aug 2026 13:49:53 +0200 Subject: [PATCH 3/4] Format secondary-nav.ts with the project prettier config Co-Authored-By: Claude Opus 5 (1M context) --- src/Elastic.Documentation.Site/Assets/secondary-nav.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Elastic.Documentation.Site/Assets/secondary-nav.ts b/src/Elastic.Documentation.Site/Assets/secondary-nav.ts index 78877f33c8..a969fdf15b 100644 --- a/src/Elastic.Documentation.Site/Assets/secondary-nav.ts +++ b/src/Elastic.Documentation.Site/Assets/secondary-nav.ts @@ -26,7 +26,8 @@ export function initSecondaryNav() { 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 + const clicked = + target?.closest(DROPDOWN) ?? undefined closeAllExcept(clicked) }) From d701ff948437a6decf74ab9dd486e4ed17b18679 Mon Sep 17 00:00:00 2001 From: Fabrizio Ferri Benedetti Date: Thu, 6 Aug 2026 14:08:30 +0200 Subject: [PATCH 4/4] Add the Guides and APIs entries to the top nav Guides points at the docs home and APIs at the API reference, matching what nav-v2 declared for the same two sections. Guides uses `/`, which prefix-matches every page, so it is the highlighted entry wherever no more specific entry claims the page. That is the intended catch-all behaviour rather than an accident of prefix matching, so cover it with a test. Also guard the shipped navigation.yml itself: its top_nav drives every assembled page, so a typo there breaks the site rather than one doc. Co-Authored-By: Claude Opus 5 (1M context) --- config/navigation.yml | 6 +++++ .../TopNavResolverTests.cs | 22 ++++++++++++++++++ .../SiteNavigationFileTests.cs | 23 +++++++++++++++++++ 3 files changed, 51 insertions(+) diff --git a/config/navigation.yml b/config/navigation.yml index f0a0bf77ac..a636d3df27 100644 --- a/config/navigation.yml +++ b/config/navigation.yml @@ -24,6 +24,12 @@ # # 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 diff --git a/tests/Elastic.Documentation.Build.Tests/TopNavResolverTests.cs b/tests/Elastic.Documentation.Build.Tests/TopNavResolverTests.cs index 189a5246cf..3beddd88c1 100644 --- a/tests/Elastic.Documentation.Build.Tests/TopNavResolverTests.cs +++ b/tests/Elastic.Documentation.Build.Tests/TopNavResolverTests.cs @@ -184,6 +184,28 @@ public void ActiveUrlPrefersTheLongestWholeSegmentMatch() 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(); diff --git a/tests/Elastic.Documentation.Configuration.Tests/SiteNavigationFileTests.cs b/tests/Elastic.Documentation.Configuration.Tests/SiteNavigationFileTests.cs index 591ae30ae0..dc5976d600 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/SiteNavigationFileTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/SiteNavigationFileTests.cs @@ -220,6 +220,29 @@ public void TopNavDefaultsToEmptyWhenAbsent() 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() {