Skip to content
Open
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
34 changes: 34 additions & 0 deletions config/navigation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions src/Elastic.ApiExplorer/Infrastructure/ApiViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions src/Elastic.Documentation.Configuration/BuildContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ public record BuildContext : IDocumentationSetContext, IDocumentationConfigurati

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 @@ -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();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<SiteNavigationFile>(yaml);

Expand Down
192 changes: 192 additions & 0 deletions src/Elastic.Documentation.Configuration/Toc/TopNavigation.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// The <c>top_nav:</c> 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 <c>url</c> or <c>page</c> as a link.
/// </summary>
public class TopNavItemCollection : List<TopNavItemConfig>;

public record TopNavItemConfig
{
public string? Title { get; init; }

/// <summary>A site relative path (<c>/reference/</c>) or an absolute <c>http(s)</c> URL.</summary>
public string? Url { get; init; }

/// <summary>A cross link URI (<c>docs-content://products/elasticsearch/v9.md</c>) resolved at assemble time.</summary>
public Uri? Page { get; init; }

public IReadOnlyList<TopNavItemConfig> Children { get; init; } = [];
}

/// <summary>
/// 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.
/// </summary>
public record TopNavRenderModel(IReadOnlyList<TopNavRenderItem> Items)
{
/// <summary>
/// The href of the entry that best covers <paramref name="currentUrl"/>, or null when none does.
/// Matching is on whole path segments, so <c>/reference/</c> does not claim <c>/references/x</c>,
/// and the longest match wins so a nested entry beats its parent.
/// </summary>
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<TopNavLinkItem> 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)
{
/// <summary>Whether this entry owns <paramref name="activeUrl"/>, as returned by <see cref="TopNavRenderModel.ActiveUrl"/>.</summary>
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<TopNavGroup> Groups) : TopNavRenderItem(Title)
{
public override bool IsActive(string? activeUrl) =>
Groups.SelectMany(g => g.Links).Any(l => l.IsActive(activeUrl));
}

/// <summary>A run of links inside a dropdown. A null <paramref name="Label"/> means the links are ungrouped.</summary>
public record TopNavGroup(string? Label, IReadOnlyList<TopNavLinkItem> 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<SequenceStart>(out _))
return collection;

while (!parser.TryConsume<SequenceEnd>(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<MappingStart>(out _))
return null;

string? title = null;
string? url = null;
string? page = null;
IReadOnlyList<TopNavItemConfig> children = [];

while (!parser.TryConsume<MappingEnd>(out _))
{
var key = parser.Consume<Scalar>();

if (parser.Accept<Scalar>(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<SequenceStart>(out _))
{
if (key.Value == "children")
{
var list = new List<TopNavItemConfig>();
_ = parser.Consume<SequenceStart>();
while (!parser.TryConsume<SequenceEnd>(out _))
{
if (rootDeserializer(typeof(TopNavItemConfig)) is TopNavItemConfig child)
list.Add(child);
}
children = list;
}
else
parser.SkipThisAndNestedEvents();
}
else if (parser.Accept<MappingStart>(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);
}
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 @@ -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'
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <details>. 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);
}
}
Loading
Loading