diff --git a/config/assembler.yml b/config/assembler.yml index 4403ff9b2..f70144aa0 100644 --- a/config/assembler.yml +++ b/config/assembler.yml @@ -29,6 +29,18 @@ environments: content_source: edge google_tag_manager: enabled: false + # TODO: remove 'local' and switch CI synthetics to use 'dev' explicitly once + # NAVIGATION_PREVIEW is the default (at which point the pages-dropdown step + # in navigation-test.journey.ts should be replaced with a top-nav step). + # 'local' exists solely so the CI synthetics job (assembler build -c local) + # falls back here instead of 'dev', keeping the flag-off secondary-nav path + # testable until the flag is retired. + local: + uri: http://localhost:4000 + content_source: current + path_prefix: docs + feature_flags: + WEBSITE_SEARCH: true dev: uri: http://localhost:4000 content_source: current diff --git a/src/Elastic.Documentation.Configuration/BuildContext.cs b/src/Elastic.Documentation.Configuration/BuildContext.cs index 36514883e..99835cdd0 100644 --- a/src/Elastic.Documentation.Configuration/BuildContext.cs +++ b/src/Elastic.Documentation.Configuration/BuildContext.cs @@ -46,12 +46,6 @@ public record BuildContext : IDocumentationSetContext, IDocumentationConfigurati public ConfigurationFile Configuration { get; private set; } 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/services/Elastic.Documentation.Assembler/Navigation/SectionTopNavBuilder.cs b/src/Elastic.Documentation.Navigation/Assembler/SectionTopNavBuilder.cs similarity index 86% rename from src/services/Elastic.Documentation.Assembler/Navigation/SectionTopNavBuilder.cs rename to src/Elastic.Documentation.Navigation/Assembler/SectionTopNavBuilder.cs index 6adc3f76a..71d7a3590 100644 --- a/src/services/Elastic.Documentation.Assembler/Navigation/SectionTopNavBuilder.cs +++ b/src/Elastic.Documentation.Navigation/Assembler/SectionTopNavBuilder.cs @@ -3,22 +3,20 @@ // See the LICENSE file in the project root for more information using Elastic.Documentation.Configuration.Toc; -using Elastic.Documentation.Navigation; -using Elastic.Documentation.Navigation.Assembler; -namespace Elastic.Documentation.Assembler.Navigation; +namespace Elastic.Documentation.Navigation.Assembler; /// /// Builds a from the top-level navigation entries in -/// navigation.yml. Supports two entry shapes: +/// navigation_preview.yml when the navigation-preview feature flag is on. +/// Supports two entry shapes: /// /// toc: — a single navigation root, becomes one tab. /// section: — a named group of toc: refs, becomes one tab whose active state -/// matches any of the grouped roots. External sections become external-link tabs. +/// matches the section's navigation root. External sections become external-link tabs. /// /// Active state is determined by comparing the current page's NavigationRoot.Id to each -/// tab's stored (or -/// for single-root tabs). +/// tab's stored . /// public static class SectionTopNavBuilder { diff --git a/src/Elastic.Documentation.Navigation/Assembler/SiteNavigation.cs b/src/Elastic.Documentation.Navigation/Assembler/SiteNavigation.cs index 0af53662b..a8ad0d7ea 100644 --- a/src/Elastic.Documentation.Navigation/Assembler/SiteNavigation.cs +++ b/src/Elastic.Documentation.Navigation/Assembler/SiteNavigation.cs @@ -15,8 +15,21 @@ namespace Elastic.Documentation.Navigation.Assembler; +/// +/// Marks the assembled site-wide navigation root, allowing layouts to discover the +/// configured top nav without knowing the concrete assembler type. +/// +public interface ISiteNavigationRoot +{ + /// + /// The site-wide top navigation when the navigation-preview feature flag is on, + /// otherwise null. When null the layout falls back to its built-in links. + /// + TopNavRenderModel? TopNav { get; } +} + [DebuggerDisplay("{Url}")] -public class SiteNavigation : IRootNavigationItem, INavigationTraversable +public class SiteNavigation : IRootNavigationItem, INavigationTraversable, ISiteNavigationRoot { private readonly string? _sitePrefix; @@ -24,7 +37,8 @@ public SiteNavigation( SiteNavigationFile siteNavigationFile, IDocumentationContext context, IReadOnlyCollection documentationSetNavigations, - string? sitePrefix + string? sitePrefix, + bool navigationPreviewEnabled = false ) { // Normalize sitePrefix to ensure it has a leading slash and no trailing slash @@ -130,8 +144,15 @@ public SiteNavigation( // Build positional navigation lookup tables from all navigation items in a single traversal NavigationDocumentationFileLookup = []; NavigationIndexedByOrder = this.BuildNavigationLookups(NavigationDocumentationFileLookup); + + // Top nav is only built when the navigation-preview flag is on. + // Index.Url values are resolved above so SectionTopNavBuilder can read them here. + TopNav = navigationPreviewEnabled ? SectionTopNavBuilder.Build(this, siteNavigationFile) : null; } + /// + public TopNavRenderModel? TopNav { get; } + public HashSet DeclaredPhantoms { get; } /// All the table of contents explicitly declared in the navigation diff --git a/src/Elastic.Documentation.Site/Assets/main.ts b/src/Elastic.Documentation.Site/Assets/main.ts index 8cfb7f371..44076d1f7 100644 --- a/src/Elastic.Documentation.Site/Assets/main.ts +++ b/src/Elastic.Documentation.Site/Assets/main.ts @@ -5,6 +5,7 @@ import { config } from './config' import { initCopyButton } from './copybutton' import { initHighlight } from './hljs' import { initImageCarousel } from './image-carousel' +import { initListing } from './listing' import { initMermaid } from './mermaid' import { openDetailsWithAnchor } from './open-details-with-anchor' import { initNav } from './pages-nav' @@ -216,6 +217,7 @@ document.addEventListener('htmx:load', function () { ['initSmoothScroll', initSmoothScroll], ['openDetailsWithAnchor', openDetailsWithAnchor], ['initImageCarousel', initImageCarousel], + ['initListing', initListing], ['initTable', initTable], ['initApiDocs', initApiDocs], ['applyEditParam', applyEditParam], diff --git a/src/Elastic.Documentation.Site/Layout/_SecondaryNav.cshtml b/src/Elastic.Documentation.Site/Layout/_SecondaryNav.cshtml index 076e0a737..569dcb898 100644 --- a/src/Elastic.Documentation.Site/Layout/_SecondaryNav.cshtml +++ b/src/Elastic.Documentation.Site/Layout/_SecondaryNav.cshtml @@ -9,16 +9,16 @@
@* Anchored below the nav (top-full) so it tracks it in both scroll states — a fixed/--offset-top indicator can't account for the variable-height diff --git a/src/Elastic.Documentation.Site/_ViewModels.cs b/src/Elastic.Documentation.Site/_ViewModels.cs index 3419192aa..4789119c8 100644 --- a/src/Elastic.Documentation.Site/_ViewModels.cs +++ b/src/Elastic.Documentation.Site/_ViewModels.cs @@ -11,6 +11,7 @@ using Elastic.Documentation.Configuration.Builder; using Elastic.Documentation.Configuration.Toc; using Elastic.Documentation.Navigation; +using Elastic.Documentation.Navigation.Assembler; using Elastic.Documentation.Site.FileProviders; namespace Elastic.Documentation.Site; @@ -53,10 +54,23 @@ public record GlobalLayoutViewModel public IReadOnlyList? CodexBreadcrumbs { get; init; } /// - /// The configured top navigation for assembler builds. When null the secondary nav renders - /// its built-in links instead. + /// The configured top navigation for assembler builds. Derived by walking the + /// parent chain to find a + /// . Returns null for isolated/codex builds, + /// and for assembler builds where the navigation-preview flag is off. /// - public TopNavRenderModel? TopNav { get; init; } + public TopNavRenderModel? TopNav + { + get + { + for (var item = (INavigationItem?)CurrentNavigationItem; item is not null; item = item.Parent) + { + if (item is ISiteNavigationRoot siteRoot) + return siteRoot.TopNav; + } + return null; + } + } /// /// When the current page is a hidden nav item (e.g. an individual detection rule page), diff --git a/src/Elastic.Markdown/HtmlWriter.cs b/src/Elastic.Markdown/HtmlWriter.cs index 62fe04ebb..40cd683c0 100644 --- a/src/Elastic.Markdown/HtmlWriter.cs +++ b/src/Elastic.Markdown/HtmlWriter.cs @@ -196,7 +196,6 @@ 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 132bdb544..d7040b304 100644 --- a/src/Elastic.Markdown/Page/Index.cshtml +++ b/src/Elastic.Markdown/Page/Index.cshtml @@ -37,7 +37,6 @@ 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 984a4f478..e2644f618 100644 --- a/src/Elastic.Markdown/Page/IndexViewModel.cs +++ b/src/Elastic.Markdown/Page/IndexViewModel.cs @@ -86,9 +86,6 @@ 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/Building/AssemblerBuildService.cs b/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildService.cs index dba064600..bfb962ac7 100644 --- a/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildService.cs +++ b/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildService.cs @@ -107,17 +107,14 @@ Cancel ctx var navigationFileInfo = configurationContext.ConfigurationFileProvider.NavigationFile; var siteNavigationFile = SiteNavigationFile.Deserialize(await fileSystem.File.ReadAllTextAsync(navigationFileInfo.FullName, ctx)); var documentationSets = assembleSources.AssembleSets.Values.Select(s => s.DocumentationSet.Navigation).ToArray(); - var navigation = new SiteNavigation(siteNavigationFile, assembleContext, documentationSets, assembleContext.Environment.PathPrefix); + var navigationPreviewEnabled = assembleContext.Environment.ToFeatureFlags().NavigationPreviewEnabled; + var navigation = new SiteNavigation(siteNavigationFile, assembleContext, documentationSets, assembleContext.Environment.PathPrefix, navigationPreviewEnabled); _logger.LogInformation("Validating navigation.yml does not contain colliding path prefixes"); // this validates all path prefixes are unique, early exit if duplicates are detected if (!SiteNavigationFile.ValidatePathPrefixes(assembleContext.Collector, siteNavigationFile, navigationFileInfo) || assembleContext.Collector.Errors > 0) return false; - var topNav = SectionTopNavBuilder.Build(navigation, siteNavigationFile); - foreach (var set in assembleSources.AssembleSets.Values) - set.BuildContext.TopNav = topNav; - var pathProvider = new GlobalNavigationPathProvider(navigation, assembleSources, assembleContext); var htmlWriter = new GlobalNavigationHtmlWriter(logFactory, navigation, collector); var legacyPageChecker = new LegacyPageService(logFactory); diff --git a/src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigationHtmlWriter.cs b/src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigationHtmlWriter.cs index 9700aede3..495b56ac8 100644 --- a/src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigationHtmlWriter.cs +++ b/src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigationHtmlWriter.cs @@ -46,7 +46,9 @@ private NavigationRenderModel CreateNavigationModel(INodeNavigationItemDocs<"); + html.Should().Contain("justify-between").And.NotContain("justify-start"); } [Fact] @@ -55,13 +60,21 @@ public async Task ConfiguredLinksReplaceTheBuiltInOnes() } [Fact] - public async Task TheBarIsLeftAlignedAndCarriesNoBrandLink() + public async Task WithTopNavTheBarIsLeftAlignedAndCarriesNoBrandLink() { - 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"); - } + var html = await Render(TopNav, "/docs/"); + + html.Should().NotContain(">Docs<"); + html.Should().Contain("justify-start").And.NotContain("justify-between"); + } + + [Fact] + public async Task WithoutTopNavTheBarHasDocsBrandLinkAndIsJustifiedBetween() + { + var html = await Render(null, "/docs/"); + + html.Should().Contain(">Docs<"); + html.Should().Contain("justify-between").And.NotContain("justify-start"); } [Fact] @@ -123,12 +136,17 @@ private async Task Render( fileSystem.AddDirectory("/docs"); var context = CreateContext(fileSystem); + // TopNav is now derived from the parent chain. Wire a MockSiteNavigationRoot as the + // immediate parent so GlobalLayoutViewModel.TopNav returns the expected value. + var siteRoot = new MockSiteNavigationRoot(topNav); + var currentNavItem = new StubNavigationItem(currentUrl, root) { Parent = siteRoot }; + var model = new GlobalLayoutViewModel { DocsBuilderVersion = "test", DocSetName = "test", Description = "", - CurrentNavigationItem = new StubNavigationItem(currentUrl, root), + CurrentNavigationItem = currentNavItem, Previous = null, Next = null, NavigationHtml = "", @@ -139,7 +157,6 @@ private async Task Render( GoogleTagManager = new GoogleTagManagerConfiguration(), Optimizely = new OptimizelyConfiguration(), StaticFileContentHashProvider = new StaticFileContentHashProvider(new EmbeddedOrPhysicalFileProvider(context)), - TopNav = topNav }; return await _SecondaryNav.Create(model).RenderAsync(cancellationToken: TestContext.Current.CancellationToken); @@ -157,6 +174,25 @@ private sealed record StubNavigationItem( public int NavigationIndex { get; set; } } + /// + /// Stands in for SiteNavigation as the outermost parent so + /// resolves correctly. + /// + private sealed class MockSiteNavigationRoot(TopNavRenderModel? topNav) + : INodeNavigationItem, ISiteNavigationRoot + { + public TopNavRenderModel? TopNav { get; } = topNav; + public string Id => "mock-site"; + public string Url => "/"; + public string NavigationTitle => "Mock Site"; + public IRootNavigationItem NavigationRoot => null!; + public INodeNavigationItem? Parent { get; set; } + public bool Hidden => false; + public int NavigationIndex { get; set; } + public ILeafNavigationItem Index => null!; + public IReadOnlyCollection NavigationItems => []; + } + /// /// Minimal root stub — _SecondaryNav.cshtml reads /// and compares its Id against each tab's SectionId(s).