Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
21e88aa
feat(nav-v2): add section: item type for independent nav islands
theletterf Apr 16, 2026
071f456
feat(nav-v2): migrate top-level labels to section: items
theletterf Apr 16, 2026
3a46434
feat(nav-v2): wrap all content in Docs section; add Release notes, Tr…
theletterf Apr 16, 2026
ef333f8
fix(nav-v2): compact left-aligned top bar, section root highlight, au…
theletterf Apr 16, 2026
23f27b3
feat(nav-v2): add Extension points and Account isolated sections; rem…
theletterf Apr 16, 2026
7fed2c5
fix: correct import ordering in PlaceholderPageWriter
theletterf Apr 16, 2026
cdabed1
feat(nav-v2): support absolute URLs for section tabs; add API reference
theletterf Apr 16, 2026
0557264
feat(nav-v2): add island: item type for intra-section nav islands
theletterf Apr 16, 2026
1e4da5a
feat(nav-v2): convert Account and preferences to island under Docs
theletterf Apr 22, 2026
ca03a50
Merge branch 'nav-v2' into nav-v2-sections
theletterf Apr 22, 2026
a0b98a3
fix(nav-v2): register island root URL in island lookup
theletterf Apr 22, 2026
b813024
fix(nav-v2): use toc root identity for island lookup instead of URL m…
theletterf Apr 22, 2026
e02fc81
fix(nav-v2): scope top-level padding to label sections only
theletterf Apr 22, 2026
6803630
style: fix Prettier formatting in styles.css
theletterf Apr 22, 2026
fdb278a
fix(nav-v2): back arrow swaps full page including sidebar
theletterf Apr 22, 2026
2d20e27
fix(nav-v2): open external section links in new tab with ↗ indicator
theletterf Apr 23, 2026
c4c5bab
feat(nav-v2): rename Docs to Guides, API reference to APIs
theletterf Apr 23, 2026
13a6695
feat(nav-v2): reorder tabs by task flow
theletterf Apr 23, 2026
4e3bde9
fix(nav-v2): shorten long nav label to 'Query languages and APIs'
theletterf Apr 23, 2026
f569f7e
Merge nav-v2 into nav-v2-sections, reconciling navigation structure
theletterf May 4, 2026
4cc9e37
Merge remote-tracking branch 'origin/nav-v2' into nav-v2-sections
theletterf May 4, 2026
52840c4
Merge nav-v2 into nav-v2-sections
theletterf May 4, 2026
6dde442
style: fix Prettier formatting in pages-nav-v2.ts and styles.css
theletterf May 4, 2026
ef8b5a6
Update top navigation order
theletterf May 7, 2026
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
5,615 changes: 2,827 additions & 2,788 deletions config/navigation-v2.yml

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions src/Elastic.Codex/Page/Index.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@
Previous = Model.PreviousDocument,
Next = Model.NextDocument,
NavigationHtml = Model.NavigationHtml,
NavV2Sections = Model.NavV2Sections,
ActiveSectionId = Model.ActiveSectionId,
UrlPathPrefix = Model.UrlPathPrefix,
GithubEditUrl = Model.GithubEditUrl,
MarkdownUrl = Model.MarkdownUrl,
Expand Down
25 changes: 25 additions & 0 deletions src/Elastic.Documentation.Configuration/Toc/NavigationV2File.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,18 @@ IReadOnlyList<INavV2Item> Children
/// </summary>
public record PageNavV2Item(Uri? Page, string? Title) : INavV2Item;

/// <summary>
/// A top-level section that owns an independent sidebar tree and (optionally) a tab in the
/// secondary nav bar. When <see cref="Isolated"/> is <c>true</c> the section does not appear
/// in the top bar and renders with a back-arrow instead.
/// </summary>
public record SectionNavV2Item(
string Label,
string Url,
bool Isolated,
IReadOnlyList<INavV2Item> Children
) : INavV2Item;

/// <summary>
/// A folder node — has a title and children, with an optional <c>page:</c> URI.
/// When <see cref="Page"/> is set, the header is a real clickable link; otherwise it renders
Expand Down Expand Up @@ -110,6 +122,19 @@ private static IReadOnlyList<INavV2Item> ReadItemList(IParser parser, ObjectDese
parser.SkipThisAndNestedEvents();
}

if (dict.TryGetValue("section", out var sectionVal) && sectionVal is string sectionStr)
{
var sectionUrl = dict.TryGetValue("url", out var suVal) && suVal is string suStr ? suStr : "/";
var isolated = dict.TryGetValue("isolated", out var isoVal)
&& isoVal is string isoStr
&& bool.TryParse(isoStr, out var isoBool)
&& isoBool;
var sectionChildren = dict.TryGetValue("children", out var sch) && sch is IReadOnlyList<INavV2Item> sChildList
? sChildList
: [];
return new SectionNavV2Item(sectionStr, sectionUrl, isolated, sectionChildren);
}

if (dict.TryGetValue("label", out var labelVal) && labelVal is string labelStr)
{
var expanded = dict.TryGetValue("expanded", out var expVal)
Expand Down
17 changes: 17 additions & 0 deletions src/Elastic.Documentation.Navigation/V2/NavigationSection.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// 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

namespace Elastic.Documentation.Navigation.V2;

/// <summary>
/// Lightweight data carrier for a navigation section, used by the rendering layer
/// to drive the secondary nav bar tabs and resolve which sidebar to show.
/// </summary>
public record NavigationSection(
string Id,
string Label,
string Url,
bool Isolated,
IReadOnlyList<INavigationItem> NavigationItems
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// 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 Elastic.Documentation.Extensions;

namespace Elastic.Documentation.Navigation.V2;

/// <summary>
/// A top-level section that owns an independent sidebar nav tree.
/// Unlike <see cref="LabelNavigationNode"/>, a section has a real URL (the tab link)
/// and an <see cref="Isolated"/> flag that controls whether it appears in the top bar.
/// </summary>
public class SectionNavigationNode : INodeNavigationItem<INavigationModel, INavigationItem>
{
private readonly SectionIndexLeaf _index;

public SectionNavigationNode(
string label,
string url,
bool isolated,
IReadOnlyCollection<INavigationItem> children,
INodeNavigationItem<INavigationModel, INavigationItem>? parent
)
{
Id = ShortId.Create("section", label);
NavigationTitle = label;
Url = url;
Isolated = isolated;
NavigationItems = children;
Parent = parent;
NavigationRoot = parent?.NavigationRoot!;
_index = new SectionIndexLeaf(this);
}

/// <summary>Whether this section is excluded from the top bar and renders with a back arrow.</summary>
public bool Isolated { get; }

/// <inheritdoc />
public string Id { get; }

/// <inheritdoc />
public string Url { get; }

/// <inheritdoc />
public string NavigationTitle { get; }

/// <inheritdoc />
public IRootNavigationItem<INavigationModel, INavigationItem> NavigationRoot { get; }

/// <inheritdoc />
public INodeNavigationItem<INavigationModel, INavigationItem>? Parent { get; set; }

/// <inheritdoc />
public bool Hidden => false;

/// <inheritdoc />
public int NavigationIndex { get; set; }

/// <inheritdoc />
public ILeafNavigationItem<INavigationModel> Index => _index;

/// <inheritdoc />
public IReadOnlyCollection<INavigationItem> NavigationItems { get; }

private sealed class SectionIndexLeaf(SectionNavigationNode owner)
: ILeafNavigationItem<INavigationModel>, INavigationModel
{
public INavigationModel Model => this;
public string Url => owner.Url;
public string NavigationTitle => owner.NavigationTitle;
public IRootNavigationItem<INavigationModel, INavigationItem> NavigationRoot => owner.NavigationRoot;
public INodeNavigationItem<INavigationModel, INavigationItem>? Parent { get; set; } = owner;
public bool Hidden => true;
public int NavigationIndex { get; set; }
}
}
86 changes: 79 additions & 7 deletions src/Elastic.Documentation.Navigation/V2/SiteNavigationV2.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,30 +10,89 @@
namespace Elastic.Documentation.Navigation.V2;

/// <summary>
/// Extends <see cref="SiteNavigation"/> with a V2 label-structured sidebar tree derived from
/// Extends <see cref="SiteNavigation"/> with a V2 section-structured sidebar derived from
/// <c>navigation-v2.yml</c>. Content is built at the same URL paths as V1 (the original
/// <paramref name="originalFile"/> is passed to the base constructor unchanged).
/// Only the sidebar presentation changes — <see cref="V2NavigationItems"/> exposes the
/// label/placeholder hierarchy used by <c>_TocTreeNavV2.cshtml</c>.
/// <para>
/// Top-level <c>section:</c> items become independent nav trees (<see cref="Sections"/>).
/// Each section drives a tab in the secondary nav bar and its own sidebar.
/// Sections marked <c>isolated: true</c> do not appear in the top bar and render with a back arrow.
/// </para>
/// </summary>
public class SiteNavigationV2 : SiteNavigation
{
private readonly Dictionary<string, NavigationSection> _urlToSection = new(StringComparer.OrdinalIgnoreCase);

public SiteNavigationV2(
NavigationV2File v2File,
SiteNavigationFile originalFile,
IDocumentationContext context,
IReadOnlyCollection<IDocumentationSetNavigation> documentationSetNavigations,
string? sitePrefix
) : base(originalFile, context, documentationSetNavigations, sitePrefix)
=> V2NavigationItems = BuildV2Items(v2File.Nav, Nodes, this, sitePrefix ?? string.Empty);
{
var prefix = sitePrefix ?? string.Empty;
V2NavigationItems = BuildV2Items(v2File.Nav, Nodes, this, prefix);
Sections = BuildSections(V2NavigationItems);
BuildUrlToSectionLookup();
}

/// <summary>
/// Label-structured navigation items for V2 sidebar rendering.
/// Contains <see cref="LabelNavigationNode"/>, <see cref="PlaceholderNavigationLeaf"/>,
/// <see cref="PageCrossLinkLeaf"/>, and existing <see cref="IRootNavigationItem{TIndex,TChildNavigation}"/> nodes.
/// All V2 navigation items (flat list including sections, labels, etc.).
/// Used for placeholder generation and full-tree traversal.
/// </summary>
public IReadOnlyList<INavigationItem> V2NavigationItems { get; }

/// <summary>
/// Top-level sections extracted from <see cref="V2NavigationItems"/>.
/// Each section owns an independent sidebar nav tree.
/// </summary>
public IReadOnlyList<NavigationSection> Sections { get; }

/// <summary>
/// Resolves which section a page belongs to by its URL.
/// Returns the first non-isolated section as fallback for unresolved URLs.
/// </summary>
public NavigationSection? GetSectionForUrl(string? pageUrl)
{
if (pageUrl is not null)
{
var normalized = pageUrl.TrimEnd('/');
if (_urlToSection.TryGetValue(normalized, out var section))
return section;
if (_urlToSection.TryGetValue(normalized + "/", out section))
return section;
}
return Sections.FirstOrDefault(s => !s.Isolated);
}

private static IReadOnlyList<NavigationSection> BuildSections(IReadOnlyList<INavigationItem> items) =>
items
.OfType<SectionNavigationNode>()
.Select(s => new NavigationSection(s.Id, s.NavigationTitle, s.Url, s.Isolated, [.. s.NavigationItems]))
.ToList();

private void BuildUrlToSectionLookup()
{
foreach (var section in Sections)
CollectUrls(section.NavigationItems, section);
}

private void CollectUrls(IEnumerable<INavigationItem> items, NavigationSection section)
{
foreach (var item in items)
{
if (!string.IsNullOrEmpty(item.Url))
{
var normalized = item.Url.TrimEnd('/');
_ = _urlToSection.TryAdd(normalized, section);
}

if (item is INodeNavigationItem<INavigationModel, INavigationItem> node)
CollectUrls(node.NavigationItems, section);
}
}

private static IReadOnlyList<INavigationItem> BuildV2Items(
IReadOnlyList<INavV2Item> v2Items,
IReadOnlyDictionary<Uri, IRootNavigationItem<IDocumentationFile, INavigationItem>> nodes,
Expand All @@ -54,6 +113,7 @@ string sitePrefix
) =>
item switch
{
SectionNavV2Item section => CreateSection(section, nodes, parent, sitePrefix),
LabelNavV2Item label => CreateLabel(label, nodes, parent, sitePrefix),
GroupNavV2Item group => CreateGroup(group, nodes, parent, sitePrefix),
TocNavV2Item toc => CreateToc(toc, nodes, parent, sitePrefix),
Expand Down Expand Up @@ -100,6 +160,18 @@ INodeNavigationItem<INavigationModel, INavigationItem> parent
public IReadOnlyCollection<INavigationItem> NavigationItems => children;
}

private static SectionNavigationNode CreateSection(
SectionNavV2Item section,
IReadOnlyDictionary<Uri, IRootNavigationItem<IDocumentationFile, INavigationItem>> nodes,
INodeNavigationItem<INavigationModel, INavigationItem> parent,
string sitePrefix
)
{
var placeholder = new SectionNavigationNode(section.Label, section.Url, section.Isolated, [], parent);
var children = BuildV2Items(section.Children, nodes, placeholder, sitePrefix);
return new SectionNavigationNode(section.Label, section.Url, section.Isolated, children, parent);
}

private static LabelNavigationNode CreateLabel(
LabelNavV2Item label,
IReadOnlyDictionary<Uri, IRootNavigationItem<IDocumentationFile, INavigationItem>> nodes,
Expand Down
35 changes: 35 additions & 0 deletions src/Elastic.Documentation.Site/Assets/pages-nav-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,23 @@ function normalizeDocPathname(pathname: string) {
return p === '' ? '/' : p
}

/**
* Returns true when the current page is the section root URL.
* Section root pages should not get current-page highlighting in the sidebar
* because the section URL is a tab target, not a page within the nav tree.
*/
function isOnSectionRootPage(nav: HTMLElement): boolean {
const sectionUrl = nav.dataset.sectionUrl
if (!sectionUrl) {
return false
}

return (
normalizeDocPathname(window.location.pathname) ===
normalizeDocPathname(sectionUrl)
)
}

/** Matches {@link markCurrentPage} / {@link expandToCurrentPage} href selectors (not root-normalized). */
function stripTrailingSlashForNavHref(pathname: string) {
return pathname.replace(/\/$/, '')
Expand Down Expand Up @@ -318,6 +335,9 @@ function deepestCurrentSidebarLink(nav: HTMLElement): HTMLAnchorElement | null {
*/
function applyActiveSubtreeHighlight(nav: HTMLElement) {
clearActiveSubtreeHighlight(nav)
if (isOnSectionRootPage(nav)) {
return
}
const current = deepestCurrentSidebarLink(nav)
if (!current || !nav.contains(current)) {
return
Expand Down Expand Up @@ -377,8 +397,13 @@ function markCurrentPageForPath(nav: HTMLElement, pathnameRaw: string) {

/**
* Mark the current page's nav link with the "current" CSS class.
* Skips marking when the current page is the section root URL.
*/
function markCurrentPage(nav: HTMLElement) {
if (isOnSectionRootPage(nav)) {
$$('.current', nav).forEach((el) => el.classList.remove('current'))
return
}
markCurrentPageForPath(nav, window.location.pathname)
}

Expand Down Expand Up @@ -461,6 +486,16 @@ function expandToCurrentPageForPath(nav: HTMLElement, pathnameRaw: string) {
* is the current page (see session storage + folder row link match).
*/
function expandToCurrentPage(nav: HTMLElement) {
if (isOnSectionRootPage(nav)) {
// On the section root page, expand all top-level folders so the
// section content is visible even though no specific page is current.
nav.querySelectorAll<HTMLInputElement>(
'#nav-tree > li > .peer > input[type="checkbox"]'
).forEach((cb) => {
cb.checked = true
})
return
}
expandToCurrentPageForPath(nav, window.location.pathname)
}

Expand Down
Loading
Loading