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
1,221 changes: 616 additions & 605 deletions config/navigation_preview.yml

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions src/Elastic.Documentation.Configuration/BuildContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Loading
Loading