Skip to content
Open
1,217 changes: 582 additions & 635 deletions config/navigation_preview.yml

Large diffs are not rendered by default.

8 changes: 7 additions & 1 deletion src/Elastic.Documentation.Configuration/BuildContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ public record BuildContext : IDocumentationSetContext, IDocumentationConfigurati
public static string Version { get; } = Assembly.GetExecutingAssembly().GetCustomAttributes<AssemblyInformationalVersionAttribute>()
.FirstOrDefault()?.InformationalVersion ?? "0.0.0";

/// <summary>The resolved documentation filesystem. All other path/scope properties are computed from this.</summary>
/// <summary>Resolved documentation filesystem. All other path/scope properties are computed from this.</summary>
public DocumentationFileSystem FileSystem { get; }

/// <summary>
Expand All @@ -46,6 +46,12 @@ 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
163 changes: 120 additions & 43 deletions src/Elastic.Documentation.Configuration/Toc/SiteNavigationFile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,20 @@ public record NavigationTocMapping
public required string SourcePathPrefix { get; init; }
}

public interface ISiteNavigationEntry
{
IReadOnlyCollection<SiteTableOfContentsRef> Children { get; }
}

public record SiteSectionRef(
string Title,
string? ExternalUrl,
IReadOnlyCollection<SiteTableOfContentsRef> Children
) : ISiteNavigationEntry
{
public bool IsExternal => ExternalUrl is not null;
}

[YamlSerializable]
public class SiteNavigationFile
{
Expand Down Expand Up @@ -53,40 +67,46 @@ public static bool ValidatePathPrefixes(IDiagnosticsCollector collector, SiteNav
public static ImmutableHashSet<Uri> GetAllDeclaredSources(SiteNavigationFile siteNavigation)
{
var set = new HashSet<Uri>();

foreach (var tocRef in siteNavigation.TableOfContents)
CollectSource(tocRef, set);

foreach (var entry in siteNavigation.TableOfContents)
{
if (entry is SiteTableOfContentsRef tocRef)
CollectSource(tocRef, set);
else
foreach (var child in entry.Children)
CollectSource(child, set);
}
return set.ToImmutableHashSet();
}

private static void CollectSource(SiteTableOfContentsRef tocRef, HashSet<Uri> set)
{
_ = set.Add(tocRef.Source);
// Recursively collect from children
foreach (var child in tocRef.Children)
CollectSource(child, set);
}

private static ImmutableHashSet<Uri> GetAllPathPrefixes(SiteNavigationFile siteNavigation)
{
var set = new HashSet<Uri>();

foreach (var tocRef in siteNavigation.TableOfContents)
CollectPathPrefixes(tocRef, set);

foreach (var entry in siteNavigation.TableOfContents)
{
if (entry is SiteTableOfContentsRef tocRef)
CollectPathPrefixes(tocRef, set);
else
foreach (var child in entry.Children)
CollectPathPrefixes(child, set);
}
return set.ToImmutableHashSet();
}

private static void CollectPathPrefixes(SiteTableOfContentsRef tocRef, HashSet<Uri> set)
{
// Add path prefix for this toc ref
if (!string.IsNullOrEmpty(tocRef.PathPrefix))
{
var pathUri = new Uri($"{tocRef.Source.Scheme}://{tocRef.PathPrefix.TrimEnd('/')}/");
_ = set.Add(pathUri);
}

// Recursively collect from children
foreach (var child in tocRef.Children)
CollectPathPrefixes(child, set);
}
Expand Down Expand Up @@ -114,14 +134,14 @@ public class PhantomRegistration
public string Source { get; set; } = null!;
}

public class SiteTableOfContents : List<SiteTableOfContentsRef>;
public class SiteTableOfContents : List<ISiteNavigationEntry>;

/// <param name="Island">
/// When <c>true</c>, the resolved navigation node is marked as an island from the assembler side.
/// OR-ed with any <c>island: true</c> the content set already declares — can only enable, never disable.
/// </param>
public record SiteTableOfContentsRef(Uri Source, string PathPrefix, IReadOnlyCollection<SiteTableOfContentsRef> Children, bool Island = false)
: ITableOfContentsItem
: ISiteNavigationEntry, ITableOfContentsItem
{
// For site-level TOC refs, the Path is the path prefix (where it will be mounted in the site)
public string PathRelativeToDocumentationSet => PathPrefix;
Expand All @@ -148,14 +168,90 @@ public object ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeseria

while (!parser.TryConsume<SequenceEnd>(out _))
{
var item = rootDeserializer(typeof(SiteTableOfContentsRef));
if (item is SiteTableOfContentsRef tocRef)
collection.Add(tocRef);
var entry = ParseTopLevelEntry(parser, rootDeserializer);
if (entry is not null)
collection.Add(entry);
}

return collection;
}

private static ISiteNavigationEntry? ParseTopLevelEntry(IParser parser, ObjectDeserializer rootDeserializer)
{
if (!parser.TryConsume<MappingStart>(out _))
return null;

var dictionary = new Dictionary<string, object?>();

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

object? value = null;
if (parser.Accept<Scalar>(out var scalarValue))
{
value = scalarValue.Value;
_ = parser.MoveNext();
}
else if (parser.Accept<SequenceStart>(out _))
{
if (key.Value is "children")
{
var childrenList = new List<SiteTableOfContentsRef>();
_ = parser.Consume<SequenceStart>();
while (!parser.TryConsume<SequenceEnd>(out _))
{
var child = rootDeserializer(typeof(SiteTableOfContentsRef));
if (child is SiteTableOfContentsRef childRef)
childrenList.Add(childRef);
}
value = childrenList;
}
else
parser.SkipThisAndNestedEvents();
}
else if (parser.Accept<MappingStart>(out _))
parser.SkipThisAndNestedEvents();

dictionary[key.Value] = value;
}

if (dictionary.TryGetValue("section", out var sectionTitleVal) && sectionTitleVal is string sectionTitle)
{
var externalUrl = dictionary.TryGetValue("external", out var extVal) && extVal is string e && !string.IsNullOrEmpty(e) ? e : null;
IReadOnlyCollection<SiteTableOfContentsRef> children = dictionary.TryGetValue("children", out var childrenObj) && childrenObj is List<SiteTableOfContentsRef> refs
? refs
: [];
return new SiteSectionRef(sectionTitle, externalUrl, children);
}

if (dictionary.TryGetValue("toc", out var tocPath) && tocPath is string sourceString)
{
var uriString = sourceString.Contains("://") ? sourceString : $"docs-content://{sourceString}";

if (!Uri.TryCreate(uriString, UriKind.Absolute, out var source))
throw new InvalidOperationException($"Invalid TOC source: '{sourceString}' could not be parsed as a URI");

var pathPrefix = dictionary.TryGetValue("path_prefix", out var pathValue) && pathValue is string path
? path
: string.Empty;

IReadOnlyCollection<SiteTableOfContentsRef> children = dictionary.TryGetValue("children", out var childrenObj2) && childrenObj2 is List<SiteTableOfContentsRef> tocRefs
? tocRefs
: [];

var island = dictionary.TryGetValue("island", out var islandObj) && islandObj is string islandStr
&& bool.TryParse(islandStr, out var islandBool) && islandBool;

return new SiteTableOfContentsRef(source, pathPrefix, children, island);
}

var keys = string.Join(", ", dictionary.Keys.Select(k => $"'{k}'"));
throw new YamlException(
$"toc entry has no 'toc:' key and will be ignored. " +
$"Found keys: {keys}. Check for typos.");
}

public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer) =>
serializer.Invoke(value, type);
}
Expand All @@ -175,7 +271,6 @@ public class SiteTableOfContentsRefYamlConverter : IYamlTypeConverter
{
var key = parser.Consume<Scalar>();

// Parse the value based on what type it is
object? value = null;
if (parser.Accept<Scalar>(out var scalarValue))
{
Expand All @@ -184,10 +279,8 @@ public class SiteTableOfContentsRefYamlConverter : IYamlTypeConverter
}
else if (parser.Accept<SequenceStart>(out _))
{
// This is a list - parse it manually for "children"
if (key.Value == "children")
{
// Parse the children list manually
var childrenList = new List<SiteTableOfContentsRef>();
_ = parser.Consume<SequenceStart>();
while (!parser.TryConsume<SequenceEnd>(out _))
Expand All @@ -199,26 +292,16 @@ public class SiteTableOfContentsRefYamlConverter : IYamlTypeConverter
value = childrenList;
}
else
{
// For other lists, just skip them
parser.SkipThisAndNestedEvents();
}
}
else if (parser.Accept<MappingStart>(out _))
{
// This is a nested mapping - skip it
parser.SkipThisAndNestedEvents();
}

dictionary[key.Value] = value;
}

var children = GetChildren(dictionary);

// Check for toc reference - required
if (dictionary.TryGetValue("toc", out var tocPath) && tocPath is string sourceString)
{
// Convert string to Uri - if no scheme, prepend "docs-content://"
var uriString = sourceString.Contains("://") ? sourceString : $"docs-content://{sourceString}";

if (!Uri.TryCreate(uriString, UriKind.Absolute, out var source))
Expand All @@ -228,28 +311,22 @@ public class SiteTableOfContentsRefYamlConverter : IYamlTypeConverter
? path
: string.Empty;

IReadOnlyCollection<SiteTableOfContentsRef> children = dictionary.TryGetValue("children", out var childrenObj) && childrenObj is List<SiteTableOfContentsRef> tocRefs
? tocRefs
: [];

var island = dictionary.TryGetValue("island", out var islandObj) && islandObj is string islandStr
&& bool.TryParse(islandStr, out var islandBool) && islandBool;

return new SiteTableOfContentsRef(source, pathPrefix, children, island);
}

return null;
}

private IReadOnlyCollection<SiteTableOfContentsRef> GetChildren(Dictionary<string, object?> dictionary)
{
if (!dictionary.TryGetValue("children", out var childrenObj))
return [];

// Children have already been deserialized as List<SiteTableOfContentsRef>
if (childrenObj is List<SiteTableOfContentsRef> tocRefs)
return tocRefs;

return [];
var keys = string.Join(", ", dictionary.Keys.Select(k => $"'{k}'"));
throw new YamlException(
$"toc entry has no 'toc:' key and will be ignored. " +
$"Found keys: {keys}. Check for typos.");
}

public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer) =>
serializer.Invoke(value, type);
}

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

/// <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.
/// The active tab is determined by comparing each item's <see cref="TopNavLinkItem.SectionId"/>
/// against the current page's <c>NavigationRoot.Id</c> — no URL prefix matching.
/// </summary>
public record TopNavRenderModel(IReadOnlyList<TopNavRenderItem> Items);

public abstract record TopNavRenderItem(string Title)
{
/// <summary>
/// Whether this tab is active given the current page's navigation root id.
/// Pass <c>null</c> when the page has no section (e.g. the homepage).
/// </summary>
public abstract bool IsActive(string? currentSectionId);
}

/// <summary>
/// A link tab. <see cref="SectionId"/> is the ID of a single navigation root that owns this tab.
/// <see cref="SectionIds"/> overrides <see cref="SectionId"/> when a tab groups multiple roots (section:
/// entries in navigation.yml). The tab is active when <c>currentSectionId</c> matches any owned ID.
/// External link tabs carry neither field and are never active.
/// </summary>
public record TopNavLinkItem(string Title, string Url, bool IsExternal, string? SectionId = null) : TopNavRenderItem(Title)
{
/// <summary>When non-null, overrides <see cref="SectionId"/> for active-state matching.</summary>
public IReadOnlySet<string>? SectionIds { get; init; }

public override bool IsActive(string? currentSectionId)
{
if (currentSectionId is null)
return false;
if (SectionIds is not null)
return SectionIds.Contains(currentSectionId);
return SectionId is not null && currentSectionId == SectionId;
}
}

/// <summary>
/// A dropdown tab with labelled link groups. Dropdowns have no tree-section membership and are never
/// marked active.
/// </summary>
public record TopNavDropdownItem(string Title, IReadOnlyList<TopNavGroup> Groups) : TopNavRenderItem(Title)
{
public override bool IsActive(string? currentSectionId) => false;
}

/// <summary>A run of links inside a dropdown. A null <see cref="Label"/> means the links are ungrouped.</summary>
public record TopNavGroup(string? Label, IReadOnlyList<TopNavLinkItem> Links);
29 changes: 14 additions & 15 deletions src/Elastic.Documentation.Navigation/Assembler/SiteNavigation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -71,23 +71,22 @@ public SiteNavigation(
}

var index = items.Count;
foreach (var tocRef in siteNavigationFile.TableOfContents)

foreach (var entry in siteNavigationFile.TableOfContents)
{
var navItem = CreateSiteTableOfContentsNavigation(
tocRef,
index++,
context,
this,
null
);

if (navItem != null)
var tocRefs = entry is SiteSectionRef section
? section.Children
: [entry as SiteTableOfContentsRef ?? throw new InvalidOperationException($"Unexpected entry type: {entry.GetType().Name}")];

foreach (var tocRef in tocRefs)
{
// Every top-level navigation.yml section owns the sidebar — that is the definition of an island.
// The dropdown is how a top-level island renders its way out; see NavigationRenderModel.CreateBackLinks.
if (navItem is IAssignableIslandNavigation assignable)
assignable.IsIsland = true;
items.Add(navItem);
var navItem = CreateSiteTableOfContentsNavigation(tocRef, index++, context, this, null);
if (navItem is not null)
{
if (navItem is IAssignableIslandNavigation assignable)
assignable.IsIsland = true;
items.Add(navItem);
}
}
}

Expand Down
Loading
Loading