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(" 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; } + } +}