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).