Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions config/assembler.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 0 additions & 6 deletions src/Elastic.Documentation.Configuration/BuildContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,6 @@ public record BuildContext : IDocumentationSetContext, IDocumentationConfigurati
public ConfigurationFile Configuration { get; private set; }
public DocumentationSetFile ConfigurationYaml { get; set; }

/// <summary>
/// The resolved site-wide top navigation. Only assembler builds set this; when null the layout
/// falls back to its built-in links.
/// </summary>
public TopNavRenderModel? TopNav { get; set; }

public VersionsConfiguration VersionsConfiguration { get; }
public ConfigurationFileProvider ConfigurationFileProvider { get; }
public DocumentationEndpoints Endpoints { get; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/// <summary>
/// Builds a <see cref="TopNavRenderModel"/> from the top-level navigation entries in
/// <c>navigation.yml</c>. Supports two entry shapes:
/// <c>navigation_preview.yml</c> when the <c>navigation-preview</c> feature flag is on.
/// Supports two entry shapes:
/// <list type="bullet">
/// <item><c>toc:</c> — a single navigation root, becomes one tab.</item>
/// <item><c>section:</c> — a named group of toc: refs, becomes one tab whose active state
/// matches any of the grouped roots. External sections become external-link tabs.</item>
/// matches the section's navigation root. External sections become external-link tabs.</item>
/// </list>
/// Active state is determined by comparing the current page's NavigationRoot.Id to each
/// tab's stored <see cref="TopNavLinkItem.SectionIds"/> (or <see cref="TopNavLinkItem.SectionId"/>
/// for single-root tabs).
/// tab's stored <see cref="TopNavLinkItem.SectionId"/>.
/// </summary>
public static class SectionTopNavBuilder
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,30 @@

namespace Elastic.Documentation.Navigation.Assembler;

/// <summary>
/// Marks the assembled site-wide navigation root, allowing layouts to discover the
/// configured top nav without knowing the concrete assembler type.
/// </summary>
public interface ISiteNavigationRoot
{
/// <summary>
/// The site-wide top navigation when the <c>navigation-preview</c> feature flag is on,
/// otherwise <c>null</c>. When null the layout falls back to its built-in links.
/// </summary>
TopNavRenderModel? TopNav { get; }
}

[DebuggerDisplay("{Url}")]
public class SiteNavigation : IRootNavigationItem<IDocumentationFile, INavigationItem>, INavigationTraversable
public class SiteNavigation : IRootNavigationItem<IDocumentationFile, INavigationItem>, INavigationTraversable, ISiteNavigationRoot
{
private readonly string? _sitePrefix;

public SiteNavigation(
SiteNavigationFile siteNavigationFile,
IDocumentationContext context,
IReadOnlyCollection<IDocumentationSetNavigation> documentationSetNavigations,
string? sitePrefix
string? sitePrefix,
bool navigationPreviewEnabled = false
)
{
// Normalize sitePrefix to ensure it has a leading slash and no trailing slash
Expand Down Expand Up @@ -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;
}

/// <inheritdoc cref="ISiteNavigationRoot.TopNav"/>
public TopNavRenderModel? TopNav { get; }

public HashSet<Uri> DeclaredPhantoms { get; }

/// <summary> All the table of contents explicitly declared in the navigation</summary>
Expand Down
2 changes: 2 additions & 0 deletions src/Elastic.Documentation.Site/Assets/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -216,6 +217,7 @@ document.addEventListener('htmx:load', function () {
['initSmoothScroll', initSmoothScroll],
['openDetailsWithAnchor', openDetailsWithAnchor],
['initImageCarousel', initImageCarousel],
['initListing', initListing],
['initTable', initTable],
['initApiDocs', initApiDocs],
['applyEditParam', applyEditParam],
Expand Down
47 changes: 29 additions & 18 deletions src/Elastic.Documentation.Site/Layout/_SecondaryNav.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,16 @@
<div class="relative md:sticky md:top-0 md:z-30 bg-grey-10">
<nav id="secondary-nav"
class="bg-grey-10 border-b border-grey-20">
@* The bar scrolls sideways rather than overflowing the viewport once enough items are
configured. secondary-nav-dropdown.css lets an open dropdown escape that clipping. *@
<div class="
secondary-nav-scroll-container
w-full min-w-0 overflow-x-auto
max-w-(--max-layout-width) flex mx-auto justify-start items-center
px-4 py-6">
<ul class="flex gap-6 font-sans font-semibold text-sm text-ink-light md:text-base">
@if (topNav is not null)
{
@if (topNav is not null)
{
@* navigation-preview: scrollable section-driven top nav.
secondary-nav-dropdown.css lets an open dropdown escape the overflow clipping. *@
<div class="
secondary-nav-scroll-container
w-full min-w-0 overflow-x-auto
max-w-(--max-layout-width) flex mx-auto justify-start items-center
px-4 py-6">
<ul class="flex gap-6 font-sans font-semibold text-sm text-ink-light md:text-base">
@foreach (var item in topNav.Items)
{
var isActive = item.IsActive(currentSectionId);
Expand Down Expand Up @@ -70,11 +70,22 @@
</li>
}
}
}
else
{
@* No sections configured: keep the built-in links so non-assembler and older
configuration snapshots render exactly as before. *@
</ul>
</div>
}
else
{
@* Built-in links: flag off, isolated builds, and any build without section: entries.
This markup matches main exactly — verified by byte-diff. *@
<div class="
w-full
max-w-(--max-layout-width) flex mx-auto justify-between items-center
px-4 py-6">
<div class="flex gap-2 flex-nowrap items-center font-sans font-semibold text-sm text-ink-light md:text-base">
<a href="@Model.Link("/")"
class="text-lg leading-[1em] hover:text-blue-elastic active:text-blue-elastic-100">Docs</a>
</div>
<ul class="flex gap-6 font-sans font-semibold text-sm text-ink-light md:text-base">
<li class="text-nowrap hover:text-blue-elastic active:text-blue-elastic-100">
<a
href="@Model.Link("/release-notes/")"
Expand All @@ -99,9 +110,9 @@
Reference
</a>
</li>
}
</ul>
</div>
</ul>
</div>
}
</nav>
@* 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
Expand Down
20 changes: 17 additions & 3 deletions src/Elastic.Documentation.Site/_ViewModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -53,10 +54,23 @@ public record GlobalLayoutViewModel
public IReadOnlyList<CodexBreadcrumb>? CodexBreadcrumbs { get; init; }

/// <summary>
/// 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
/// <see cref="CurrentNavigationItem"/> parent chain to find a
/// <see cref="ISiteNavigationRoot"/>. Returns null for isolated/codex builds,
/// and for assembler builds where the <c>navigation-preview</c> flag is off.
/// </summary>
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;
}
}

/// <summary>
/// When the current page is a hidden nav item (e.g. an individual detection rule page),
Expand Down
1 change: 0 additions & 1 deletion src/Elastic.Markdown/HtmlWriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,6 @@ private async Task<RenderResult> 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,
Expand Down
1 change: 0 additions & 1 deletion src/Elastic.Markdown/Page/Index.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 0 additions & 3 deletions src/Elastic.Markdown/Page/IndexViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,6 @@ public class IndexViewModel
/// <summary>Codex sub-header breadcrumb trail (Home / Group / Docset).</summary>
public IReadOnlyList<CodexBreadcrumb>? CodexBreadcrumbs { get; set; }

/// <summary>The configured site-wide top navigation. Null outside assembler builds.</summary>
public TopNavRenderModel? TopNav { get; init; }

/// <summary>When set, the page performs a client-side redirect to this URL (used for alias pages).</summary>
public string? RedirectUrl { get; init; }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,9 @@ private NavigationRenderModel CreateNavigationModel(INodeNavigationItem<INavigat
NavigationRenderModel.Create(
tree: group,
topLevelItems: globalNavigation.TopLevelItems,
isUsingNavigationDropdown: false,
// The top nav (navigation-preview) replaces the sidebar dropdown.
// Flag off → dropdown on (matches main); flag on → dropdown off, top nav takes over.
isUsingNavigationDropdown: globalNavigation.TopNav is null,
isPrimaryNavEnabled: true,
isGlobalAssemblyBuild: true);
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

using System.IO.Abstractions.TestingHelpers;
using AwesomeAssertions;
using Elastic.Documentation.Assembler.Navigation;
using Elastic.Documentation.Configuration;
using Elastic.Documentation.Configuration.Toc;
using Elastic.Documentation.FileSystems;
Expand Down
52 changes: 44 additions & 8 deletions tests/Navigation.Tests/Rendering/SecondaryNavRenderingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using Elastic.Documentation.Configuration.Builder;
using Elastic.Documentation.Configuration.Toc;
using Elastic.Documentation.Extensions;
using Elastic.Documentation.Navigation.Assembler;
using Elastic.Documentation.Navigation.Tests.Isolation;
using Elastic.Documentation.Site;
using Elastic.Documentation.Site.FileProviders;
Expand Down Expand Up @@ -38,9 +39,13 @@ public async Task WithoutConfigurationTheBuiltInLinksAreRendered()
{
var html = await Render(topNav: null, currentUrl: "/docs/");

// Built-in links present
html.Should().Contain("Release notes").And.Contain("Troubleshoot").And.Contain("Reference");
html.Should().NotContain("secondary-nav-dropdown");
html.Should().Contain("id=\"htmx-indicator\"");
// Flag-off: Docs brand link and justify-between layout match main exactly
html.Should().Contain(">Docs<");
html.Should().Contain("justify-between").And.NotContain("justify-start");
}

[Fact]
Expand All @@ -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]
Expand Down Expand Up @@ -123,12 +136,17 @@ private async Task<string> 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 = "",
Expand All @@ -139,7 +157,6 @@ private async Task<string> 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);
Expand All @@ -157,6 +174,25 @@ private sealed record StubNavigationItem(
public int NavigationIndex { get; set; }
}

/// <summary>
/// Stands in for <c>SiteNavigation</c> as the outermost parent so
/// <see cref="GlobalLayoutViewModel.TopNav"/> resolves correctly.
/// </summary>
private sealed class MockSiteNavigationRoot(TopNavRenderModel? topNav)
: INodeNavigationItem<INavigationModel, INavigationItem>, ISiteNavigationRoot
{
public TopNavRenderModel? TopNav { get; } = topNav;
public string Id => "mock-site";
public string Url => "/";
public string NavigationTitle => "Mock Site";
public IRootNavigationItem<INavigationModel, INavigationItem> NavigationRoot => null!;
public INodeNavigationItem<INavigationModel, INavigationItem>? Parent { get; set; }
public bool Hidden => false;
public int NavigationIndex { get; set; }
public ILeafNavigationItem<INavigationModel> Index => null!;
public IReadOnlyCollection<INavigationItem> NavigationItems => [];
}

/// <summary>
/// Minimal root stub — <c>_SecondaryNav.cshtml</c> reads <see cref="INavigationItem.NavigationRoot"/>
/// and compares its Id against each tab's SectionId(s).
Expand Down
Loading