diff --git a/src/Elastic.Documentation.Navigation/Assembler/SiteNavigation.cs b/src/Elastic.Documentation.Navigation/Assembler/SiteNavigation.cs index 4f9c0c7f6..291c80bed 100644 --- a/src/Elastic.Documentation.Navigation/Assembler/SiteNavigation.cs +++ b/src/Elastic.Documentation.Navigation/Assembler/SiteNavigation.cs @@ -2,17 +2,21 @@ // 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 System.Collections; +using System.Collections.Frozen; using System.Collections.Immutable; using System.Diagnostics; +using System.Runtime.CompilerServices; using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Configuration.Toc; using Elastic.Documentation.Extensions; +using Elastic.Documentation.Navigation.Isolated.Leaf; using Elastic.Documentation.Navigation.Isolated.Node; namespace Elastic.Documentation.Navigation.Assembler; [DebuggerDisplay("{Url}")] -public class SiteNavigation : IRootNavigationItem +public class SiteNavigation : IRootNavigationItem, INavigationTraversable { private readonly string? _sitePrefix; @@ -51,7 +55,22 @@ public SiteNavigation( UnseenNodes = [.. _nodes.Keys]; // Build NavigationItems from SiteTableOfContentsRef items var items = new List(); - var index = 0; + // The root file leafs of the narrative repository act as root leafs for the overall site + if (_nodes.TryGetValue(new Uri($"{NarrativeRepository.RepositoryName}://"), out var root)) + { + if (root is INavigationHomeAccessor accessor) + accessor.HomeProvider = new NavigationHomeProvider(_sitePrefix ?? "/", this); + root.Parent = this; + root.Index.Parent = this; + items.Add(root.Index); + foreach (var leaf in root.NavigationItems.OfType>()) + { + leaf.Parent = root; + items.Add(leaf); + } + } + + var index = items.Count; foreach (var tocRef in siteNavigationFile.TableOfContents) { var navItem = CreateSiteTableOfContentsNavigation( @@ -82,6 +101,9 @@ public SiteNavigation( value.Parent = this; } + // Build positional navigation lookup tables from all navigation items in a single traversal + NavigationDocumentationFileLookup = []; + NavigationIndexedByOrder = this.BuildNavigationLookups(NavigationDocumentationFileLookup); } public HashSet DeclaredPhantoms { get; } @@ -136,6 +158,12 @@ public SiteNavigation( void IAssignableChildrenNavigation.SetNavigationItems(IReadOnlyCollection navigationItems) => throw new NotSupportedException("SetNavigationItems is not supported on SiteNavigation"); + /// + public ConditionalWeakTable NavigationDocumentationFileLookup { get; } + + /// + public FrozenDictionary NavigationIndexedByOrder { get; } + /// /// Normalizes the site prefix to ensure it has a leading slash and no trailing slash. /// Returns null for null or empty/whitespace input. @@ -270,4 +298,5 @@ void IAssignableChildrenNavigation.SetNavigationItems(IReadOnlyCollection MarkdownNavigationLookup { get; } + ConditionalWeakTable NavigationDocumentationFileLookup { get; } FrozenDictionary NavigationIndexedByOrder { get; } - FrozenDictionary> NavigationIndexedByCrossLink { get; } - INavigationItem? GetPrevious(MarkdownFile current) + IEnumerable YieldAll() + { + if (NavigationIndexedByOrder.Count == 0) + yield break; + var current = NavigationIndexedByOrder.Values.First(); + yield return current; + do + { + current = GetNext(current); + if (current is not null) + yield return current; + + } while (current is not null); + } + + INavigationItem? GetPrevious(IDocumentationFile current) + { + var currentNavigation = GetNavigationFor(current); + return GetPrevious(currentNavigation); + } + + private INavigationItem? GetPrevious(INavigationItem currentNavigation) { - var currentNavigation = GetCurrent(current); var index = currentNavigation.NavigationIndex; do { @@ -24,14 +42,19 @@ public interface IPositionalNavigation if (previous is not null && !previous.Hidden && previous.Url != currentNavigation.Url) return previous; index--; - } while (index > 0); + } while (index >= 0); return null; } - INavigationItem? GetNext(MarkdownFile current) + INavigationItem? GetNext(IDocumentationFile current) + { + var currentNavigation = GetNavigationFor(current); + return GetNext(currentNavigation); + } + + private INavigationItem? GetNext(INavigationItem currentNavigation) { - var currentNavigation = GetCurrent(current); var index = currentNavigation.NavigationIndex; do { @@ -44,9 +67,9 @@ public interface IPositionalNavigation return null; } - INavigationItem GetCurrent(MarkdownFile file) => - MarkdownNavigationLookup.TryGetValue(file, out var navigation) - ? navigation : throw new InvalidOperationException($"Could not find {file.RelativePath} in navigation"); + INavigationItem GetNavigationFor(IDocumentationFile file) => + NavigationDocumentationFileLookup.TryGetValue(file, out var navigation) + ? navigation : throw new InvalidOperationException($"Could not find {file.NavigationTitle} in navigation"); INavigationItem[] GetParents(INavigationItem current) { @@ -65,6 +88,6 @@ INavigationItem[] GetParents(INavigationItem current) return [.. parents]; } - INavigationItem[] GetParentsOfMarkdownFile(MarkdownFile file) => - MarkdownNavigationLookup.TryGetValue(file, out var navigation) ? GetParents(navigation) : []; + INavigationItem[] GetParentsOfMarkdownFile(IDocumentationFile file) => + NavigationDocumentationFileLookup.TryGetValue(file, out var navigation) ? GetParents(navigation) : []; } diff --git a/src/Elastic.Documentation.Navigation/NavigationItemExtensions.cs b/src/Elastic.Documentation.Navigation/NavigationItemExtensions.cs index 8ea0d6908..dbdd32779 100644 --- a/src/Elastic.Documentation.Navigation/NavigationItemExtensions.cs +++ b/src/Elastic.Documentation.Navigation/NavigationItemExtensions.cs @@ -2,7 +2,10 @@ // 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 System.Collections.Frozen; +using System.Runtime.CompilerServices; using Elastic.Documentation.Navigation.Isolated; +using Elastic.Documentation.Navigation.Isolated.Leaf; namespace Elastic.Documentation.Navigation; @@ -71,4 +74,55 @@ private static void ProcessNavigationItem(IDocumentationContext context, ref int break; } } + + /// + /// Builds navigation lookups by traversing the navigation tree and populating both the + /// NavigationDocumentationFileLookup and NavigationIndexedByOrder collections. + /// + /// The root navigation item to start traversing from + /// The ConditionalWeakTable to populate with file-to-navigation mappings + /// A frozen dictionary mapping navigation indices to navigation items + public static FrozenDictionary BuildNavigationLookups( + this INavigationItem rootItem, ConditionalWeakTable navigationDocumentationFileLookup + ) + { + var navigationByOrder = new Dictionary(); + BuildNavigationLookupsRecursive(rootItem, navigationDocumentationFileLookup, navigationByOrder); + return navigationByOrder.ToFrozenDictionary(); + } + + /// + /// Recursively builds both NavigationDocumentationFileLookup and NavigationIndexedByOrder in a single traversal + /// + private static void BuildNavigationLookupsRecursive( + INavigationItem item, + ConditionalWeakTable navigationDocumentationFileLookup, + Dictionary navigationByOrder) + { + switch (item) + { + // CrossLinkNavigationLeaf is not added to NavigationDocumentationFileLookup or NavigationIndexedByOrder + case CrossLinkNavigationLeaf: + break; + case ILeafNavigationItem documentationFileLeaf: + _ = navigationDocumentationFileLookup.TryAdd(documentationFileLeaf.Model, documentationFileLeaf); + _ = navigationByOrder.TryAdd(documentationFileLeaf.NavigationIndex, documentationFileLeaf); + break; + case ILeafNavigationItem leaf: + _ = navigationByOrder.TryAdd(leaf.NavigationIndex, leaf); + break; + case INodeNavigationItem documentationFileNode: + _ = navigationDocumentationFileLookup.TryAdd(documentationFileNode.Index.Model, documentationFileNode); + _ = navigationByOrder.TryAdd(documentationFileNode.NavigationIndex, documentationFileNode); + _ = navigationByOrder.TryAdd(documentationFileNode.Index.NavigationIndex, documentationFileNode.Index); + foreach (var child in documentationFileNode.NavigationItems) + BuildNavigationLookupsRecursive(child, navigationDocumentationFileLookup, navigationByOrder); + break; + case INodeNavigationItem node: + _ = navigationByOrder.TryAdd(node.NavigationIndex, node); + foreach (var child in node.NavigationItems) + BuildNavigationLookupsRecursive(child, navigationDocumentationFileLookup, navigationByOrder); + break; + } + } } diff --git a/src/Elastic.Markdown/DocumentationGenerator.cs b/src/Elastic.Markdown/DocumentationGenerator.cs index 99de500eb..067f1ece2 100644 --- a/src/Elastic.Markdown/DocumentationGenerator.cs +++ b/src/Elastic.Markdown/DocumentationGenerator.cs @@ -9,6 +9,7 @@ using Elastic.Documentation.Configuration.LegacyUrlMappings; using Elastic.Documentation.Configuration.Versions; using Elastic.Documentation.Links; +using Elastic.Documentation.Navigation; using Elastic.Documentation.Serialization; using Elastic.Documentation.Site.FileProviders; using Elastic.Documentation.Site.Navigation; @@ -53,6 +54,7 @@ public class DocumentationGenerator public DocumentationGenerator( DocumentationSet docSet, ILoggerFactory logFactory, + INavigationTraversable? positionalNavigation = null, INavigationHtmlWriter? navigationHtmlWriter = null, IDocumentationFileOutputProvider? documentationFileOutputProvider = null, IMarkdownExporter[]? markdownExporters = null, @@ -69,7 +71,7 @@ public DocumentationGenerator( DocumentationSet = docSet; Context = docSet.Context; var productVersionInferrer = new ProductVersionInferrerService(DocumentationSet.Context.ProductsConfiguration, DocumentationSet.Context.VersionsConfiguration); - HtmlWriter = new HtmlWriter(DocumentationSet, _writeFileSystem, new DescriptionGenerator(), navigationHtmlWriter, legacyUrlMapper, productVersionInferrer); + HtmlWriter = new HtmlWriter(DocumentationSet, _writeFileSystem, new DescriptionGenerator(), positionalNavigation, navigationHtmlWriter, legacyUrlMapper, productVersionInferrer); _documentationFileExporter = docSet.Context.AvailableExporters.Contains(Exporter.Html) ? docSet.EnabledExtensions.FirstOrDefault(e => e.FileExporter != null)?.FileExporter diff --git a/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchMarkdownExporter.cs b/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchMarkdownExporter.cs index ae453e9ef..bf0716fbb 100644 --- a/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchMarkdownExporter.cs +++ b/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchMarkdownExporter.cs @@ -9,6 +9,7 @@ using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Synonyms; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.Navigation; using Elastic.Documentation.Search; using Elastic.Ingest.Elasticsearch; using Elastic.Ingest.Elasticsearch.Indices; @@ -382,8 +383,8 @@ private async ValueTask DoReindex(PostData request, string lexicalWriteAlias, st public async ValueTask ExportAsync(MarkdownExportFileContext fileContext, Cancel ctx) { var file = fileContext.SourceFile; - IPositionalNavigation navigation = fileContext.DocumentationSet; - var currentNavigation = navigation.GetCurrent(file); + INavigationTraversable navigation = fileContext.DocumentationSet; + var currentNavigation = navigation.GetNavigationFor(file); var url = currentNavigation.Url; if (url is "/docs" or "/docs/404") diff --git a/src/Elastic.Markdown/HtmlWriter.cs b/src/Elastic.Markdown/HtmlWriter.cs index 2e8431caa..6a886f92e 100644 --- a/src/Elastic.Markdown/HtmlWriter.cs +++ b/src/Elastic.Markdown/HtmlWriter.cs @@ -24,6 +24,7 @@ public class HtmlWriter( DocumentationSet documentationSet, IFileSystem writeFileSystem, IDescriptionGenerator descriptionGenerator, + INavigationTraversable? positionalNavigation = null, INavigationHtmlWriter? navigationHtmlWriter = null, ILegacyUrlMapper? legacyUrlMapper = null, IVersionInferrerService? versionInferrerService = null @@ -37,7 +38,7 @@ public class HtmlWriter( private StaticFileContentHashProvider StaticFileContentHashProvider { get; } = new(new EmbeddedOrPhysicalFileProvider(documentationSet.Context)); private ILegacyUrlMapper LegacyUrlMapper { get; } = legacyUrlMapper ?? new NoopLegacyUrlMapper(); - private IPositionalNavigation PositionalNavigation { get; } = documentationSet; + private INavigationTraversable NavigationTraversable { get; } = positionalNavigation ?? documentationSet; private IVersionInferrerService VersionInferrerService { get; } = versionInferrerService ?? new NoopVersionInferrer(); @@ -59,7 +60,7 @@ private async Task RenderLayout(MarkdownFile markdown, MarkdownDoc { var html = MarkdownFile.CreateHtml(document); await DocumentationSet.ResolveDirectoryTree(ctx); - var navigationItem = DocumentationSet.FindNavigationByMarkdown(markdown); + var navigationItem = NavigationTraversable.GetNavigationFor(markdown); var root = navigationItem.NavigationRoot; @@ -67,10 +68,10 @@ private async Task RenderLayout(MarkdownFile markdown, MarkdownDoc ? await NavigationHtmlWriter.RenderNavigation(root, navigationItem, 1, ctx) : await NavigationHtmlWriter.RenderNavigation(root, navigationItem, INavigationHtmlWriter.AllLevels, ctx); - var current = PositionalNavigation.GetCurrent(markdown); - var previous = PositionalNavigation.GetPrevious(markdown); - var next = PositionalNavigation.GetNext(markdown); - var parents = PositionalNavigation.GetParentsOfMarkdownFile(markdown); + var current = NavigationTraversable.GetNavigationFor(markdown); + var previous = NavigationTraversable.GetPrevious(markdown); + var next = NavigationTraversable.GetNext(markdown); + var parents = NavigationTraversable.GetParentsOfMarkdownFile(markdown); var remote = DocumentationSet.Context.Git.RepositoryName; var branch = DocumentationSet.Context.Git.Branch; diff --git a/src/Elastic.Markdown/IO/DocumentationSet.cs b/src/Elastic.Markdown/IO/DocumentationSet.cs index 6ae6cefcc..91a29d903 100644 --- a/src/Elastic.Markdown/IO/DocumentationSet.cs +++ b/src/Elastic.Markdown/IO/DocumentationSet.cs @@ -13,7 +13,6 @@ using Elastic.Documentation.Links; using Elastic.Documentation.Links.CrossLinks; using Elastic.Documentation.Navigation; -using Elastic.Documentation.Navigation.Isolated.Leaf; using Elastic.Documentation.Navigation.Isolated.Node; using Elastic.Documentation.Site.Navigation; using Elastic.Markdown.Extensions; @@ -23,7 +22,7 @@ namespace Elastic.Markdown.IO; -public class DocumentationSet : IPositionalNavigation +public class DocumentationSet : INavigationTraversable { private readonly ILogger _logger; public BuildContext Context { get; } @@ -44,7 +43,7 @@ public class DocumentationSet : IPositionalNavigation public FrozenDictionary Files { get; } - public ConditionalWeakTable MarkdownNavigationLookup { get; } + public ConditionalWeakTable NavigationDocumentationFileLookup { get; } public IReadOnlyCollection EnabledExtensions { get; } @@ -69,7 +68,7 @@ ICrossLinkResolver linkResolver CrossLinkResolver = CrossLinkResolver, TryFindDocument = TryFindDocument, TryFindDocumentByRelativePath = TryFindDocumentByRelativePath, - PositionalNavigation = this + NavigationTraversable = this }; MarkdownParser = new MarkdownParser(context, resolver); @@ -90,32 +89,12 @@ ICrossLinkResolver linkResolver var markdownFiles = files.OfType().ToArray(); MarkdownFiles = markdownFiles.ToFrozenSet(); - MarkdownNavigationLookup = []; - var navigationFlatList = CreateNavigationLookup(Navigation); - NavigationIndexedByOrder = navigationFlatList - .DistinctBy(n => n.NavigationIndex) - .ToDictionary(n => n.NavigationIndex, n => n) - .ToFrozenDictionary(); - - // Build cross-link dictionary including both: - // 1. Direct leaf items (files without children) - // 2. Index property of node items (files with children) - var leafItems = navigationFlatList.OfType>(); - var nodeIndexes = navigationFlatList - .OfType>() - .Select(node => node.Index); - - NavigationIndexedByCrossLink = leafItems - .Concat(nodeIndexes) - .DistinctBy(n => n.Model.CrossLink) - .ToDictionary(n => n.Model.CrossLink, n => n) - .ToFrozenDictionary(); + NavigationDocumentationFileLookup = []; + NavigationIndexedByOrder = Navigation.BuildNavigationLookups(NavigationDocumentationFileLookup); ValidateRedirectsExists(); } - public FrozenDictionary> NavigationIndexedByCrossLink { get; } - public DocumentationSetNavigation Navigation { get; } public FrozenDictionary NavigationIndexedByOrder { get; } @@ -137,30 +116,6 @@ private void VisitNavigation(INavigationItem item) } } - private IReadOnlyCollection CreateNavigationLookup(INavigationItem item) - { - switch (item) - { - case ILeafNavigationItem markdownLeaf: - var added = MarkdownNavigationLookup.TryAdd(markdownLeaf.Model, markdownLeaf); - if (!added) - Context.EmitWarning(Configuration.SourceFile, $"Duplicate navigation item {markdownLeaf.Model.CrossLink}"); - return [markdownLeaf]; - case ILeafNavigationItem crossLink: - return [crossLink]; - case ILeafNavigationItem leaf: - throw new Exception($"Should not be possible to have a leaf navigation item that is not a markdown file: {leaf.Model.GetType().FullName}"); - case INodeNavigationItem node: - _ = MarkdownNavigationLookup.TryAdd(node.Index.Model, node); - var nodeItems = node.NavigationItems.SelectMany(CreateNavigationLookup); - return nodeItems.Concat([node, node.Index]).ToArray(); - case INodeNavigationItem node: - throw new Exception($"Should not be possible to have a leaf navigation item that is not a markdown file: {node.GetType().FullName}"); - default: - return []; - } - } - private void ValidateRedirectsExists() { if (Configuration.Redirects is null || Configuration.Redirects.Count == 0) @@ -235,7 +190,7 @@ void ValidateExists(string from, string to, IReadOnlyDictionary public INavigationItem FindNavigationByMarkdown(MarkdownFile markdown) { - if (MarkdownNavigationLookup.TryGetValue(markdown, out var navigation)) + if (NavigationDocumentationFileLookup.TryGetValue(markdown, out var navigation)) return navigation; throw new Exception($"Could not find navigation item for {markdown.CrossLink}"); } diff --git a/src/Elastic.Markdown/Myst/Directives/Stepper/StepViewModel.cs b/src/Elastic.Markdown/Myst/Directives/Stepper/StepViewModel.cs index 5336e9d3f..89e85f8ca 100644 --- a/src/Elastic.Markdown/Myst/Directives/Stepper/StepViewModel.cs +++ b/src/Elastic.Markdown/Myst/Directives/Stepper/StepViewModel.cs @@ -19,7 +19,7 @@ public class StepViewModel : DirectiveViewModel public required string Anchor { get; init; } public required int HeadingLevel { get; init; } - public class StepCrossNavigationLookupProvider : IPositionalNavigation + public class StepCrossNavigationLookupProvider : INavigationTraversable { public static StepCrossNavigationLookupProvider Instance { get; } = new(); @@ -27,11 +27,7 @@ public class StepCrossNavigationLookupProvider : IPositionalNavigation public FrozenDictionary NavigationIndexedByOrder { get; } = new Dictionary().ToFrozenDictionary(); /// - public FrozenDictionary> NavigationIndexedByCrossLink { get; } = - new Dictionary>().ToFrozenDictionary(); - - /// - public ConditionalWeakTable MarkdownNavigationLookup { get; } = []; + public ConditionalWeakTable NavigationDocumentationFileLookup { get; } = []; } public class StepCrossLinkResolver : ICrossLinkResolver @@ -67,7 +63,7 @@ public HtmlString RenderTitle() TryFindDocument = _ => null!, TryFindDocumentByRelativePath = _ => null!, CrossLinkResolver = StepCrossLinkResolver.Instance, - PositionalNavigation = StepCrossNavigationLookupProvider.Instance + NavigationTraversable = StepCrossNavigationLookupProvider.Instance }); var document = Markdig.Markdown.Parse(Title, MarkdownParser.Pipeline, context); diff --git a/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs b/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs index eaf91bda5..ec6fa2167 100644 --- a/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs +++ b/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs @@ -220,7 +220,7 @@ private static void ProcessInternalLink(LinkInline link, InlineProcessor process { if (context.TryFindDocument(context.MarkdownSourcePath) is MarkdownFile currentMarkdown) { - if (context.PositionalNavigation.MarkdownNavigationLookup.TryGetValue(currentMarkdown, out var navigationLookup)) + if (context.NavigationTraversable.NavigationDocumentationFileLookup.TryGetValue(currentMarkdown, out var navigationLookup)) link.SetData("NavigationRoot", navigationLookup.NavigationRoot); if (link.IsImage) @@ -235,7 +235,7 @@ private static void ProcessInternalLink(LinkInline link, InlineProcessor process var linkMarkdown = context.TryFindDocument(file) as MarkdownFile; if (linkMarkdown is not null) { - if (context.PositionalNavigation.MarkdownNavigationLookup.TryGetValue(linkMarkdown, out var navigationLookup)) + if (context.NavigationTraversable.NavigationDocumentationFileLookup.TryGetValue(linkMarkdown, out var navigationLookup)) link.SetData("TargetNavigationRoot", navigationLookup.NavigationRoot); } @@ -321,7 +321,7 @@ private static void UpdateLinkUrl(LinkInline link, MarkdownFile? linkMarkdown, s var newUrl = url; if (linkMarkdown is not null) { - if (context.PositionalNavigation.MarkdownNavigationLookup.TryGetValue(linkMarkdown, out var navigationLookup) + if (context.NavigationTraversable.NavigationDocumentationFileLookup.TryGetValue(linkMarkdown, out var navigationLookup) && !string.IsNullOrEmpty(navigationLookup.Url)) { // Navigation URLs are absolute and start with / @@ -402,7 +402,7 @@ public static string UpdateRelativeUrl(ParserContext context, string url) if (context.Build.AssemblerBuild && context.TryFindDocument(fi) is MarkdownFile currentMarkdown) { // Acquire navigation-aware path - if (context.PositionalNavigation.MarkdownNavigationLookup.TryGetValue(currentMarkdown, out var currentNavigation)) + if (context.NavigationTraversable.NavigationDocumentationFileLookup.TryGetValue(currentMarkdown, out var currentNavigation)) { var uri = new Uri(new UriBuilder("http", "localhost", 80, currentNavigation.Url).Uri, url); newUrl = uri.AbsolutePath; diff --git a/src/Elastic.Markdown/Myst/MarkdownParser.cs b/src/Elastic.Markdown/Myst/MarkdownParser.cs index d1120c4eb..1aab91540 100644 --- a/src/Elastic.Markdown/Myst/MarkdownParser.cs +++ b/src/Elastic.Markdown/Myst/MarkdownParser.cs @@ -46,7 +46,7 @@ private Task ParseFromFile(IFileInfo path, YamlFrontMatter? ma TryFindDocument = Resolvers.TryFindDocument, TryFindDocumentByRelativePath = Resolvers.TryFindDocumentByRelativePath, CrossLinkResolver = Resolvers.CrossLinkResolver, - PositionalNavigation = Resolvers.PositionalNavigation, + NavigationTraversable = Resolvers.NavigationTraversable, SkipValidation = skip }; var context = new ParserContext(state); @@ -70,7 +70,7 @@ public static MarkdownDocument ParseMarkdownStringAsync(BuildContext build, IPar YamlFrontMatter = matter, TryFindDocument = resolvers.TryFindDocument, TryFindDocumentByRelativePath = resolvers.TryFindDocumentByRelativePath, - PositionalNavigation = resolvers.PositionalNavigation, + NavigationTraversable = resolvers.NavigationTraversable, CrossLinkResolver = resolvers.CrossLinkResolver }; var context = new ParserContext(state); @@ -92,7 +92,7 @@ public static Task ParseSnippetAsync(BuildContext build, IPars TryFindDocument = resolvers.TryFindDocument, TryFindDocumentByRelativePath = resolvers.TryFindDocumentByRelativePath, CrossLinkResolver = resolvers.CrossLinkResolver, - PositionalNavigation = resolvers.PositionalNavigation, + NavigationTraversable = resolvers.NavigationTraversable, ParentMarkdownPath = parentPath }; var context = new ParserContext(state); diff --git a/src/Elastic.Markdown/Myst/ParserContext.cs b/src/Elastic.Markdown/Myst/ParserContext.cs index d4c541035..01280082e 100644 --- a/src/Elastic.Markdown/Myst/ParserContext.cs +++ b/src/Elastic.Markdown/Myst/ParserContext.cs @@ -6,6 +6,7 @@ using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Builder; using Elastic.Documentation.Links.CrossLinks; +using Elastic.Documentation.Navigation; using Elastic.Markdown.Diagnostics; using Elastic.Markdown.IO; using Elastic.Markdown.Myst.FrontMatter; @@ -30,7 +31,7 @@ public interface IParserResolvers ICrossLinkResolver CrossLinkResolver { get; } Func TryFindDocument { get; } Func TryFindDocumentByRelativePath { get; } - IPositionalNavigation PositionalNavigation { get; } + INavigationTraversable NavigationTraversable { get; } } public record ParserResolvers : IParserResolvers @@ -41,7 +42,7 @@ public record ParserResolvers : IParserResolvers public required Func TryFindDocumentByRelativePath { get; init; } - public required IPositionalNavigation PositionalNavigation { get; init; } + public required INavigationTraversable NavigationTraversable { get; init; } } public record ParserState(BuildContext Build) : ParserResolvers @@ -69,7 +70,7 @@ public class ParserContext : MarkdownParserContext, IParserResolvers public Func TryFindDocumentByRelativePath { get; } public IReadOnlyDictionary Substitutions { get; } public IReadOnlyDictionary ContextSubstitutions { get; } - public IPositionalNavigation PositionalNavigation { get; } + public INavigationTraversable NavigationTraversable { get; } public ParserContext(ParserState state) { @@ -83,12 +84,12 @@ public ParserContext(ParserState state) MarkdownSourcePath = state.MarkdownSourcePath; TryFindDocument = state.TryFindDocument; TryFindDocumentByRelativePath = state.TryFindDocumentByRelativePath; - PositionalNavigation = state.PositionalNavigation; + NavigationTraversable = state.NavigationTraversable; CurrentUrlPath = string.Empty; if (TryFindDocument(state.ParentMarkdownPath ?? MarkdownSourcePath) is MarkdownFile document) { - if (PositionalNavigation.MarkdownNavigationLookup.TryGetValue(document, out var navigationLookup)) + if (NavigationTraversable.NavigationDocumentationFileLookup.TryGetValue(document, out var navigationLookup)) CurrentUrlPath = navigationLookup.Url; } diff --git a/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildService.cs b/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildService.cs index 129251f30..3726a0e7d 100644 --- a/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildService.cs +++ b/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildService.cs @@ -85,7 +85,7 @@ Cancel ctx var legacyPageChecker = new LegacyPageService(logFactory); var historyMapper = new PageLegacyUrlMapper(legacyPageChecker, assembleContext.VersionsConfiguration, assembleSources.LegacyUrlMappings); - var builder = new AssemblerBuilder(logFactory, assembleContext, htmlWriter, pathProvider, historyMapper); + var builder = new AssemblerBuilder(logFactory, assembleContext, navigation, htmlWriter, pathProvider, historyMapper); await builder.BuildAllAsync(assembleContext.Environment, assembleSources.AssembleSets, exporters, ctx); diff --git a/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuilder.cs b/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuilder.cs index be9219d3c..d3218331f 100644 --- a/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuilder.cs +++ b/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuilder.cs @@ -10,11 +10,10 @@ using Elastic.Documentation.Configuration.LegacyUrlMappings; using Elastic.Documentation.Links; using Elastic.Documentation.Links.CrossLinks; -using Elastic.Documentation.Navigation.Assembler; +using Elastic.Documentation.Navigation; using Elastic.Documentation.Serialization; using Elastic.Markdown; using Elastic.Markdown.Exporters; -using Elastic.Markdown.Helpers; using Microsoft.Extensions.Logging; namespace Elastic.Documentation.Assembler.Building; @@ -22,6 +21,7 @@ namespace Elastic.Documentation.Assembler.Building; public class AssemblerBuilder( ILoggerFactory logFactory, AssembleContext context, + INavigationTraversable navigationTraversable, GlobalNavigationHtmlWriter writer, GlobalNavigationPathProvider pathProvider, ILegacyUrlMapper? legacyUrlMapper @@ -29,6 +29,8 @@ public class AssemblerBuilder( { private readonly ILogger _logger = logFactory.CreateLogger(); + private INavigationTraversable NavigationTraversable { get; } = navigationTraversable; + private GlobalNavigationHtmlWriter HtmlWriter { get; } = writer; private ILegacyUrlMapper? LegacyUrlMapper { get; } = legacyUrlMapper; @@ -133,7 +135,7 @@ private async Task BuildAsync(AssemblerDocumentationSet set, I SetFeatureFlags(set); var generator = new DocumentationGenerator( set.DocumentationSet, - logFactory, HtmlWriter, + logFactory, NavigationTraversable, HtmlWriter, pathProvider, legacyUrlMapper: LegacyUrlMapper, markdownExporters: markdownExporters diff --git a/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs b/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs index 335d7441c..94a521c66 100644 --- a/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs +++ b/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs @@ -118,7 +118,7 @@ public async Task Build( await Task.WhenAll(tasks); - var generator = new DocumentationGenerator(set, logFactory, null, null, markdownExporters.ToArray()); + var generator = new DocumentationGenerator(set, logFactory, set, null, null, markdownExporters.ToArray()); _ = await generator.GenerateAll(ctx); var openApiGenerator = new OpenApiGenerator(logFactory, context, generator.MarkdownStringRenderer); diff --git a/tests-integration/Elastic.Assembler.IntegrationTests/NavigationBuildingTests.cs b/tests-integration/Elastic.Assembler.IntegrationTests/NavigationBuildingTests.cs index cced32841..9c519e484 100644 --- a/tests-integration/Elastic.Assembler.IntegrationTests/NavigationBuildingTests.cs +++ b/tests-integration/Elastic.Assembler.IntegrationTests/NavigationBuildingTests.cs @@ -14,7 +14,6 @@ using Elastic.Documentation.Configuration.Toc; using Elastic.Documentation.Navigation; using Elastic.Documentation.Navigation.Assembler; -using Elastic.Documentation.Navigation.Isolated; using Elastic.Documentation.Navigation.Isolated.Leaf; using Elastic.Documentation.ServiceDefaults; using Elastic.Documentation.Site.Navigation; @@ -27,11 +26,11 @@ namespace Elastic.Assembler.IntegrationTests; public class NavigationBuildingTests(DocumentationFixture fixture, ITestOutputHelper output) : IAsyncLifetime { - [Fact(Skip = "Disabling this since it can't run on CI, dig in why Assert.SkipWhen doesn't work")] + [Fact(Skip = "Assert.SkipWhen not working on CI")] public async Task AssertRealNavigation() { //Skipping on CI since this relies on checking out private repositories - Assert.SkipWhen(Environment.GetEnvironmentVariable("CI") == "true", "Skipping in CI"); + Assert.SkipWhen(!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("CI")), "Skipping in CI"); string[] args = []; var builder = Host.CreateApplicationBuilder() .AddDocumentationServiceDefaults(ref args, (s, p) => @@ -116,10 +115,11 @@ public async Task AssertRealNavigation() await collector.StopAsync(TestContext.Current.CancellationToken); - collector.Errors.Should().Be(0); + collector.Errors.Should().Be(0); } + private static void RecurseNav(INodeNavigationItem navigation) { foreach (var nav in navigation.NavigationItems) diff --git a/tests-integration/Elastic.Assembler.IntegrationTests/NavigationRootTests.cs b/tests-integration/Elastic.Assembler.IntegrationTests/NavigationRootTests.cs new file mode 100644 index 000000000..d31ac7f96 --- /dev/null +++ b/tests-integration/Elastic.Assembler.IntegrationTests/NavigationRootTests.cs @@ -0,0 +1,90 @@ +// 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 System.IO.Abstractions; +using System.IO.Abstractions.TestingHelpers; +using AngleSharp; +using Documentation.Builder; +using Elastic.Documentation; +using Elastic.Documentation.Assembler; +using Elastic.Documentation.Assembler.Sourcing; +using Elastic.Documentation.Configuration; +using Elastic.Documentation.Configuration.Assembler; +using Elastic.Documentation.Configuration.Toc; +using Elastic.Documentation.Navigation; +using Elastic.Documentation.Navigation.Assembler; +using Elastic.Documentation.Navigation.Isolated; +using Elastic.Documentation.Navigation.Isolated.Leaf; +using Elastic.Documentation.ServiceDefaults; +using Elastic.Documentation.Site.Navigation; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using RazorSlices; + +namespace Elastic.Assembler.IntegrationTests; + +public class NavigationRootTests(DocumentationFixture fixture, ITestOutputHelper output) : IAsyncLifetime +{ + [Fact(Skip = "Assert.SkipWhen not working on CI")] + public async Task AssertRealNavigation() + { + //Skipping on CI since this relies on checking out private repositories + Assert.SkipWhen(!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("CI")), "Skipping in CI"); + string[] args = []; + var builder = Host.CreateApplicationBuilder() + .AddDocumentationServiceDefaults(ref args, (s, p) => + { + _ = s.AddSingleton(AssemblyConfiguration.Create(p)); + }) + .AddDocumentationToolingDefaults(); + var host = builder.Build(); + + var configurationContext = host.Services.GetRequiredService(); + + var assemblyConfiguration = AssemblyConfiguration.Create(configurationContext.ConfigurationFileProvider); + var collector = new TestDiagnosticsCollector(TestContext.Current.TestOutputHelper); + var fs = new FileSystem(); + var assembleContext = new AssembleContext(assemblyConfiguration, configurationContext, "dev", collector, fs, new MockFileSystem(), null, null); + var logFactory = new TestLoggerFactory(TestContext.Current.TestOutputHelper); + var cloner = new AssemblerRepositorySourcer(logFactory, assembleContext); + var checkoutResult = cloner.GetAll(); + var checkouts = checkoutResult.Checkouts.ToArray(); + _ = collector.StartAsync(TestContext.Current.CancellationToken); + + if (checkouts.Length == 0) + throw new Exception("No checkouts found"); + + var ctx = TestContext.Current.CancellationToken; + var assembleSources = await AssembleSources.AssembleAsync(logFactory, assembleContext, checkouts, configurationContext, new HashSet(), ctx); + + var navigationFileInfo = configurationContext.ConfigurationFileProvider.NavigationFile; + var siteNavigationFile = SiteNavigationFile.Deserialize(await fs.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 allowedRoots = navigation.TopLevelItems.Concat([navigation]).ToHashSet(); + foreach (var item in ((INavigationTraversable)navigation).YieldAll()) + item.NavigationRoot.Should().BeOneOf(allowedRoots, "Navigation for '{0}' has bad root '{1}'", item.Url, item.NavigationRoot.Identifier); + + foreach (var item in ((INavigationTraversable)navigation).NavigationIndexedByOrder.Values) + item.NavigationRoot.Should().BeOneOf(allowedRoots, "Navigation for '{0}' has bad root '{1}' indexed by order {2}", item.Url, item.NavigationRoot.Identifier, item.NavigationIndex); + + collector.Errors.Should().Be(0); + } + + /// + public ValueTask DisposeAsync() + { + GC.SuppressFinalize(this); + if (TestContext.Current.TestState?.Result is TestResult.Passed) + return default; + foreach (var resource in fixture.InMemoryLogger.RecordedLogs) + output.WriteLine(resource.Message); + return default; + } + + /// + public ValueTask InitializeAsync() => default; +} diff --git a/tests-integration/Elastic.Assembler.IntegrationTests/SiteNavigationTests.cs b/tests-integration/Elastic.Assembler.IntegrationTests/SiteNavigationTests.cs index 90e265595..3d394833f 100644 --- a/tests-integration/Elastic.Assembler.IntegrationTests/SiteNavigationTests.cs +++ b/tests-integration/Elastic.Assembler.IntegrationTests/SiteNavigationTests.cs @@ -10,6 +10,7 @@ using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Configuration.Toc; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.Navigation; using Elastic.Documentation.Navigation.Assembler; using Elastic.Markdown.IO; using FluentAssertions; @@ -171,7 +172,7 @@ public async Task ParsesSiteNavigation() navigation.TopLevelItems.Count.Should().BeLessThan(20); // Verify parent-child relationships - var firstTopLevelItem = navigation.NavigationItems.First(); + var firstTopLevelItem = navigation.NavigationItems.OfType>().First(); firstTopLevelItem.Should().NotBeNull(); firstTopLevelItem.Parent.Should().Be(navigation); firstTopLevelItem.NavigationRoot.Should().Be(firstTopLevelItem); diff --git a/tests-integration/Elastic.Assembler.IntegrationTests/TestLogger.cs b/tests-integration/Elastic.Assembler.IntegrationTests/TestLogger.cs index b4faaf910..89b5a4d52 100644 --- a/tests-integration/Elastic.Assembler.IntegrationTests/TestLogger.cs +++ b/tests-integration/Elastic.Assembler.IntegrationTests/TestLogger.cs @@ -41,8 +41,8 @@ public void Write(Diagnostic diagnostic) } } -public class TestDiagnosticsCollector(ITestOutputHelper output) - : DiagnosticsCollector([new TestDiagnosticsOutput(output)]) +public class TestDiagnosticsCollector(ITestOutputHelper? output) + : DiagnosticsCollector(output != null ? [new TestDiagnosticsOutput(output)] : []) { private readonly List _diagnostics = []; diff --git a/tests/Elastic.Markdown.Tests/DocSet/BreadCrumbTests.cs b/tests/Elastic.Markdown.Tests/DocSet/BreadCrumbTests.cs index 5c1198c14..fb785ce7b 100644 --- a/tests/Elastic.Markdown.Tests/DocSet/BreadCrumbTests.cs +++ b/tests/Elastic.Markdown.Tests/DocSet/BreadCrumbTests.cs @@ -3,7 +3,7 @@ // See the LICENSE file in the project root for more information using Elastic.Documentation.Extensions; -using Elastic.Markdown.IO; +using Elastic.Documentation.Navigation; using FluentAssertions; namespace Elastic.Markdown.Tests.DocSet; @@ -11,10 +11,12 @@ namespace Elastic.Markdown.Tests.DocSet; public class BreadCrumbTests(ITestOutputHelper output) : NavigationTestsBase(output) { [Fact] - public void ParsesATableOfContents() + public void CanQueryParentsSuccessfully() { - IPositionalNavigation positionalNavigation = Generator.DocumentationSet; - var allKeys = positionalNavigation.NavigationIndexedByCrossLink.Keys; + var documentationSet = Generator.DocumentationSet; + INavigationTraversable navigationTraversable = documentationSet; + var crossLinks = Generator.DocumentationSet.MarkdownFiles.ToDictionary(f => $"docs-builder://{f.RelativePath.OptionalWindowsReplace()}"); + var allKeys = crossLinks.Keys.ToList(); allKeys.Should().Contain("docs-builder://testing/nested/index.md"); allKeys.Should().Contain("docs-builder://testing/nest-under-index/index.md"); @@ -24,17 +26,17 @@ public void ParsesATableOfContents() doc.Should().NotBeNull(); - var f = positionalNavigation.NavigationIndexedByCrossLink.FirstOrDefault(kv => kv.Key == "docs-builder://testing/deeply-nested/foo.md"); + var f = crossLinks.FirstOrDefault(kv => kv.Key == "docs-builder://testing/deeply-nested/foo.md"); f.Should().NotBeNull(); - positionalNavigation.NavigationIndexedByCrossLink.Should().ContainKey(doc.CrossLink); - var nav = positionalNavigation.NavigationIndexedByCrossLink[doc.CrossLink]; + crossLinks.Should().ContainKey(doc.CrossLink); + var nav = navigationTraversable.GetNavigationFor(crossLinks[doc.CrossLink]); nav.Parent.Should().NotBeNull(); - _ = positionalNavigation.MarkdownNavigationLookup.TryGetValue(doc, out var docNavigation); + var docNavigation = navigationTraversable.GetNavigationFor(doc); docNavigation.Should().NotBeNull(); - var parents = positionalNavigation.GetParentsOfMarkdownFile(doc); + var parents = navigationTraversable.GetParentsOfMarkdownFile(doc); parents.Should().HaveCount(2); diff --git a/tests/Elastic.Markdown.Tests/DocSet/NestedTocTests.cs b/tests/Elastic.Markdown.Tests/DocSet/NestedTocTests.cs index 8d68a6a67..e2ee50dc2 100644 --- a/tests/Elastic.Markdown.Tests/DocSet/NestedTocTests.cs +++ b/tests/Elastic.Markdown.Tests/DocSet/NestedTocTests.cs @@ -20,10 +20,10 @@ public void InjectsNestedTocsIntoDocumentationSet() var doc = Generator.DocumentationSet.MarkdownFiles.FirstOrDefault(f => f.RelativePath == Path.Combine("development", "index.md")); doc.Should().NotBeNull(); - IPositionalNavigation positionalNavigation = Generator.DocumentationSet; - positionalNavigation.MarkdownNavigationLookup.Should().ContainKey(doc); - if (!positionalNavigation.MarkdownNavigationLookup.TryGetValue(doc, out var nav)) - throw new Exception($"Could not find nav item for {doc.CrossLink}"); + INavigationTraversable navigationTraversable = Generator.DocumentationSet; + navigationTraversable.GetNavigationFor(doc).Should().NotBeNull(); + var nav = navigationTraversable.GetNavigationFor(doc) + ?? throw new Exception($"Could not find nav item for {doc.CrossLink}"); nav.Should().BeOfType>(); var parent = nav.Parent; diff --git a/tests/Elastic.Markdown.Tests/Inline/ImagePathResolutionTests.cs b/tests/Elastic.Markdown.Tests/Inline/ImagePathResolutionTests.cs index 6ee67d23c..f234116a7 100644 --- a/tests/Elastic.Markdown.Tests/Inline/ImagePathResolutionTests.cs +++ b/tests/Elastic.Markdown.Tests/Inline/ImagePathResolutionTests.cs @@ -104,9 +104,9 @@ private async Task ResolveUrlForBuildMode(string relativeAssetPath, bool // For assembler builds DocumentationSetNavigation seeds MarkdownNavigationLookup with navigation items whose Url already // includes the computed path_prefix. To exercise the same branch in isolation, inject a stub navigation entry with the // expected Url (and minimal metadata for the surrounding API contract). - _ = documentationSet.MarkdownNavigationLookup.Remove(markdownFile); - documentationSet.MarkdownNavigationLookup.Add(markdownFile, new NavigationItemStub(navigationUrl)); - documentationSet.MarkdownNavigationLookup.TryGetValue(markdownFile, out var navigation).Should() + _ = documentationSet.NavigationDocumentationFileLookup.Remove(markdownFile); + documentationSet.NavigationDocumentationFileLookup.Add(markdownFile, new NavigationItemStub(navigationUrl)); + documentationSet.NavigationDocumentationFileLookup.TryGetValue(markdownFile, out var navigation).Should() .BeTrue("navigation lookup should contain current page"); navigation?.Url.Should().Be(navigationUrl); @@ -117,7 +117,7 @@ private async Task ResolveUrlForBuildMode(string relativeAssetPath, bool CrossLinkResolver = documentationSet.CrossLinkResolver, TryFindDocument = file => documentationSet.TryFindDocument(file), TryFindDocumentByRelativePath = path => documentationSet.TryFindDocumentByRelativePath(path), - PositionalNavigation = documentationSet + NavigationTraversable = documentationSet }; var context = new ParserContext(parserState); diff --git a/tests/authoring/Framework/Setup.fs b/tests/authoring/Framework/Setup.fs index 4eedfc5dc..2f2bbdbea 100644 --- a/tests/authoring/Framework/Setup.fs +++ b/tests/authoring/Framework/Setup.fs @@ -303,7 +303,7 @@ type Setup = let set = DocumentationSet(context, logger, linkResolver) - let generator = DocumentationGenerator(set, logger, null, null, null, conversionCollector) + let generator = DocumentationGenerator(set, logger, null, null, null, null, conversionCollector) let context = { Collector = collector