diff --git a/src/Elastic.Documentation.Site/Navigation/INavigationHtmlWriter.cs b/src/Elastic.Documentation.Site/Navigation/INavigationHtmlWriter.cs index c26a89a674..6a7ce3e844 100644 --- a/src/Elastic.Documentation.Site/Navigation/INavigationHtmlWriter.cs +++ b/src/Elastic.Documentation.Site/Navigation/INavigationHtmlWriter.cs @@ -15,10 +15,16 @@ Task RenderNavigation( Cancel ctx = default ); - async Task Render(NavigationViewModel model, Cancel ctx) + async Task Render(NavigationViewModel model, Cancel ctx) { - var slice = _TocTree.Create(model); - return await slice.RenderAsync(cancellationToken: ctx); + var renderModel = NavigationRenderModel.Create(model); + var slice = _TocTree.Create(renderModel); + var html = await slice.RenderAsync(cancellationToken: ctx); + return new NavigationRenderResult + { + Html = html, + Id = renderModel.ContentHash + }; } } public record NavigationRenderResult @@ -26,7 +32,7 @@ public record NavigationRenderResult public static NavigationRenderResult Empty { get; } = new() { Html = string.Empty, - Id = "empty-navigation" // random id + Id = "empty-navigation" }; public required string Html { get; init; } diff --git a/src/Elastic.Documentation.Site/Navigation/IsolatedBuildNavigationHtmlWriter.cs b/src/Elastic.Documentation.Site/Navigation/IsolatedBuildNavigationHtmlWriter.cs index 5d566c3a62..7241aa2ff1 100644 --- a/src/Elastic.Documentation.Site/Navigation/IsolatedBuildNavigationHtmlWriter.cs +++ b/src/Elastic.Documentation.Site/Navigation/IsolatedBuildNavigationHtmlWriter.cs @@ -2,10 +2,8 @@ // 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.Concurrent; using Elastic.Documentation; using Elastic.Documentation.Configuration; -using Elastic.Documentation.Extensions; using Elastic.Documentation.Navigation; namespace Elastic.Documentation.Site.Navigation; @@ -13,31 +11,17 @@ namespace Elastic.Documentation.Site.Navigation; public class IsolatedBuildNavigationHtmlWriter(BuildContext context, IRootNavigationItem siteRoot) : INavigationHtmlWriter { - private readonly ConcurrentDictionary _renderedNavigationCache = []; + private readonly NavigationRenderCache _renderedNavigationCache = new(); - public async Task RenderNavigation( + public Task RenderNavigation( IRootNavigationItem currentRootNavigation, INavigationItem currentNavigationItem, Cancel ctx = default) { var navigation = SelectNavigationRoot(currentRootNavigation); - var id = ShortId.Create($"{navigation.Id.GetHashCode()}"); - if (_renderedNavigationCache.TryGetValue(navigation.Id, out var value)) - { - return new NavigationRenderResult - { - Html = value, - Id = id - }; - } - var model = CreateNavigationModel(navigation); - value = await ((INavigationHtmlWriter)this).Render(model, ctx); - _renderedNavigationCache[navigation.Id] = value; - return new NavigationRenderResult - { - Html = value, - Id = id - }; + return _renderedNavigationCache.GetOrRenderAsync( + navigation, + () => ((INavigationHtmlWriter)this).Render(CreateNavigationModel(navigation), ctx)); } /// @@ -58,8 +42,6 @@ private IRootNavigationItem SelectNavigationR private NavigationViewModel CreateNavigationModel(IRootNavigationItem navigation) => new() { - Title = navigation.NavigationTitle, - TitleUrl = navigation.Url, Tree = navigation, IsPrimaryNavEnabled = context.Configuration.Features.PrimaryNavEnabled, IsUsingNavigationDropdown = context.Configuration.Features.PrimaryNavEnabled || navigation.IsUsingNavigationDropdown, diff --git a/src/Elastic.Documentation.Site/Navigation/NavigationRenderCache.cs b/src/Elastic.Documentation.Site/Navigation/NavigationRenderCache.cs new file mode 100644 index 0000000000..583cd855b2 --- /dev/null +++ b/src/Elastic.Documentation.Site/Navigation/NavigationRenderCache.cs @@ -0,0 +1,36 @@ +// 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.Collections.Concurrent; +using Elastic.Documentation.Navigation; + +namespace Elastic.Documentation.Site.Navigation; + +/// +/// Renders each navigation root at most once per build and shares the result with every page under it. +/// Keyed on root identity rather than because ids are not unique across +/// roots. Concurrent callers for the same root await a single render; a failed render is evicted so a +/// cancelled or faulted first caller does not poison the cache for later pages. +/// +public sealed class NavigationRenderCache +{ + private readonly ConcurrentDictionary, Lazy>> _cache = + new(ReferenceEqualityComparer.Instance); + + public async Task GetOrRenderAsync( + IRootNavigationItem root, + Func> render) + { + var pending = _cache.GetOrAdd(root, _ => new Lazy>(render, LazyThreadSafetyMode.ExecutionAndPublication)); + try + { + return await pending.Value.ConfigureAwait(false); + } + catch (Exception) + { + _ = _cache.TryRemove(root, out _); + throw; + } + } +} diff --git a/src/Elastic.Documentation.Site/Navigation/NavigationRenderModel.cs b/src/Elastic.Documentation.Site/Navigation/NavigationRenderModel.cs new file mode 100644 index 0000000000..f30d7edff6 --- /dev/null +++ b/src/Elastic.Documentation.Site/Navigation/NavigationRenderModel.cs @@ -0,0 +1,184 @@ +// 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.Buffers.Binary; +using System.Security.Cryptography; +using System.Text; +using Elastic.Documentation.Navigation; + +namespace Elastic.Documentation.Site.Navigation; + +public enum NavigationRenderNodeKind +{ + /// The root index page, surfaced as the first item only when primary nav is off. + IndexLink, + Link, + Folder +} + +/// A fully resolved navigation tree node; the only tree data the nav templates consume. +public sealed record NavigationRenderNode +{ + public required NavigationRenderNodeKind Kind { get; init; } + public required bool IsTopLevel { get; init; } + public required string NavigationTitle { get; init; } + public required string Url { get; init; } + /// Badge parsed from a [ns]/[cmd]/[alias] title prefix; doubles as its CSS class suffix. + public string? Badge { get; init; } + /// Only projected for folders, where it drives the expand/collapse checkbox and its persisted state. + public string? Id { get; init; } + public bool ShowToggle { get; init; } + public IReadOnlyList NavigationItems { get; init; } = []; +} + +public sealed record NavigationDropdownItem(string NavigationTitle, string Url, bool IsActive); + +/// +/// Everything _TocTree.cshtml renders, resolved from the domain navigation up front. +/// identifies the preserved tree content: pages whose trees are identical +/// share a nav-tree-* id so htmx keeps the sidebar DOM (and its expand/collapse state) alive, +/// while any visible change produces a new id and swaps in fresh HTML. +/// +public sealed record NavigationRenderModel +{ + public required bool IsUsingNavigationDropdown { get; init; } + public required string CurrentTopLevelNavigationTitle { get; init; } + public required string CurrentTopLevelUrl { get; init; } + public required IReadOnlyList DropdownItems { get; init; } + public required IReadOnlyList Tree { get; init; } + /// Hash of the preserved tree content only; the dropdown and search live outside the preserved element. + public required string ContentHash { get; init; } + + public static NavigationRenderModel Create(NavigationViewModel model) + { + var topLevelItems = model.TopLevelItems.ToArray(); + var currentTopLevelItem = topLevelItems.FirstOrDefault(i => i.Id == model.Tree.Id) ?? model.Tree; + var tree = CreateTree(model); + return new NavigationRenderModel + { + IsUsingNavigationDropdown = model.IsUsingNavigationDropdown, + CurrentTopLevelNavigationTitle = currentTopLevelItem.NavigationTitle, + CurrentTopLevelUrl = currentTopLevelItem.Url, + DropdownItems = model.IsUsingNavigationDropdown + ? [.. topLevelItems.Select(i => new NavigationDropdownItem(i.NavigationTitle, i.Url, i.NavigationRoot.Id == model.Tree.Id))] + : [], + Tree = tree, + ContentHash = HashTree(tree) + }; + } + + private static List CreateTree(NavigationViewModel model) + { + var nodes = new List(); + if (!model.IsGlobalAssemblyBuild && !model.IsPrimaryNavEnabled && !model.Tree.Index.Hidden) + { + nodes.Add(new NavigationRenderNode + { + Kind = NavigationRenderNodeKind.IndexLink, + IsTopLevel = true, + NavigationTitle = model.Tree.Index.NavigationTitle, + Url = model.Tree.Index.Url + }); + } + nodes.AddRange(CreateNavigationItems(model.Tree, isTopLevel: true)); + return nodes; + } + + private static IEnumerable CreateNavigationItems( + INodeNavigationItem parent, + bool isTopLevel) + { + foreach (var item in parent.NavigationItems) + { + if (item.Hidden) + continue; + if (item.Parent is not null && item.Parent.Index == item) + continue; + + if (item is INodeNavigationItem { NavigationItems.Count: > 0 } folder) + yield return CreateFolder(folder, isTopLevel); + else if (item is INodeNavigationItem or ILeafNavigationItem) + yield return CreateLink(item, isTopLevel); + } + } + + private static NavigationRenderNode CreateFolder(INodeNavigationItem folder, bool isTopLevel) + { + var (badge, navigationTitle) = ParseNavTitle(folder.NavigationTitle); + return new NavigationRenderNode + { + Kind = NavigationRenderNodeKind.Folder, + IsTopLevel = isTopLevel, + NavigationTitle = navigationTitle, + Badge = badge, + Url = folder.Url, + Id = folder.Id, + ShowToggle = !folder.NavigationItems.All(n => n.Hidden), + NavigationItems = [.. CreateNavigationItems(folder, isTopLevel: false)] + }; + } + + private static NavigationRenderNode CreateLink(INavigationItem item, bool isTopLevel) + { + var (badge, navigationTitle) = ParseNavTitle(item.NavigationTitle); + return new NavigationRenderNode + { + Kind = NavigationRenderNodeKind.Link, + IsTopLevel = isTopLevel, + NavigationTitle = navigationTitle, + Badge = badge, + Url = item.Url + }; + } + + private static (string? Badge, string NavigationTitle) ParseNavTitle(string raw) + { + if (raw.StartsWith("[ns]", StringComparison.Ordinal)) + return ("ns", raw[4..]); + if (raw.StartsWith("[cmd]", StringComparison.Ordinal)) + return ("cmd", raw[5..]); + if (raw.StartsWith("[alias]", StringComparison.Ordinal)) + return ("alias", raw[7..]); + return (null, raw); + } + + private static string HashTree(IReadOnlyList tree) + { + using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + Append(hash, "navigation-tree-v1"); + AppendNodes(hash, tree); + return Convert.ToHexStringLower(hash.GetHashAndReset().AsSpan(0, 8)); + } + + private static void AppendNodes(IncrementalHash hash, IReadOnlyList nodes) + { + AppendInt(hash, nodes.Count); + foreach (var node in nodes) + { + AppendInt(hash, (int)node.Kind); + AppendInt(hash, node.IsTopLevel ? 1 : 0); + Append(hash, node.NavigationTitle); + Append(hash, node.Badge ?? string.Empty); + Append(hash, node.Url); + Append(hash, node.Id ?? string.Empty); + AppendInt(hash, node.ShowToggle ? 1 : 0); + AppendNodes(hash, node.NavigationItems); + } + } + + // Length-prefixed fields make the byte stream unambiguous without separator escaping + private static void Append(IncrementalHash hash, string value) + { + var bytes = Encoding.UTF8.GetBytes(value); + AppendInt(hash, bytes.Length); + hash.AppendData(bytes); + } + + private static void AppendInt(IncrementalHash hash, int value) + { + Span buffer = stackalloc byte[4]; + BinaryPrimitives.WriteInt32LittleEndian(buffer, value); + hash.AppendData(buffer); + } +} diff --git a/src/Elastic.Documentation.Site/Navigation/NavigationTreeItem.cs b/src/Elastic.Documentation.Site/Navigation/NavigationTreeItem.cs deleted file mode 100644 index 509618da08..0000000000 --- a/src/Elastic.Documentation.Site/Navigation/NavigationTreeItem.cs +++ /dev/null @@ -1,16 +0,0 @@ -// 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.Navigation; - -namespace Elastic.Documentation.Site.Navigation; - -public class NavigationTreeItem -{ - public required int Level { get; init; } - //public required MarkdownFile CurrentDocument { get; init; } - public required INodeNavigationItem SubTree { get; init; } - public required bool IsPrimaryNavEnabled { get; init; } - public required bool IsGlobalAssemblyBuild { get; init; } -} diff --git a/src/Elastic.Documentation.Site/Navigation/NavigationViewModel.cs b/src/Elastic.Documentation.Site/Navigation/NavigationViewModel.cs index a1d87eaa96..15dd0a38f4 100644 --- a/src/Elastic.Documentation.Site/Navigation/NavigationViewModel.cs +++ b/src/Elastic.Documentation.Site/Navigation/NavigationViewModel.cs @@ -10,8 +10,6 @@ namespace Elastic.Documentation.Site.Navigation; public class NavigationViewModel { - public required string Title { get; init; } - public required string TitleUrl { get; init; } public required INodeNavigationItem Tree { get; init; } public required bool IsPrimaryNavEnabled { get; init; } public required bool IsGlobalAssemblyBuild { get; init; } diff --git a/src/Elastic.Documentation.Site/Navigation/_TocTree.cshtml b/src/Elastic.Documentation.Site/Navigation/_TocTree.cshtml index 94b3ea91ef..46e0edef14 100644 --- a/src/Elastic.Documentation.Site/Navigation/_TocTree.cshtml +++ b/src/Elastic.Documentation.Site/Navigation/_TocTree.cshtml @@ -1,10 +1,7 @@ @using Elastic.Documentation.Site.Navigation -@inherits RazorSlice +@inherits RazorSlice
- @{ - var currentTopLevelItem = Model.TopLevelItems.FirstOrDefault(i => i.Id == Model.Tree.Id) ?? Model.Tree; - }
- @* Group-scoped id: hx-preserve keeps the tree (and its expand/collapse state) across - same-group navigations; a different group renders a different id, so no match and - the new tree swaps in. *@ -
diff --git a/src/Elastic.Documentation.Site/Navigation/_TocTreeNav.cshtml b/src/Elastic.Documentation.Site/Navigation/_TocTreeNav.cshtml index f59bc3d9f3..d186444f44 100644 --- a/src/Elastic.Documentation.Site/Navigation/_TocTreeNav.cshtml +++ b/src/Elastic.Documentation.Site/Navigation/_TocTreeNav.cshtml @@ -1,101 +1,47 @@ -@using Elastic.Documentation.Navigation -@using Elastic.Documentation.Navigation.Isolated -@using Elastic.Documentation.Navigation.Isolated.Leaf @using Elastic.Documentation.Site.Navigation -@inherits RazorSlice -@{ - var isTopLevel = Model.Level == 0; -} -@functions { - static (string? badge, string label) ParseNavTitle(string raw) - { - if (raw.StartsWith("[ns]", StringComparison.Ordinal)) return ("ns", raw[4..]); - if (raw.StartsWith("[cmd]", StringComparison.Ordinal)) return ("cmd", raw[5..]); - if (raw.StartsWith("[alias]", StringComparison.Ordinal)) return ("alias", raw[7..]); - return (null, raw); - } -} -@if (isTopLevel && !Model.IsGlobalAssemblyBuild && !Model.IsPrimaryNavEnabled && !Model.SubTree.Index.Hidden) +@inherits RazorSlice> +@foreach (var item in Model) { - var idx = Model.SubTree.Index; -
  • - @idx.NavigationTitle -
  • -} -@foreach (var item in Model.SubTree.NavigationItems) -{ - if (item.Hidden) + if (item.Kind == NavigationRenderNodeKind.IndexLink) { - continue; - } - - if (item.Parent is not null && item.Parent.Index == item) - { - continue; +
  • + @item.NavigationTitle +
  • } - if (item is INodeNavigationItem { NavigationItems.Count: 0 } group) + else if (item.Kind == NavigationRenderNodeKind.Link) { - var (groupBadge, groupLabel) = ParseNavTitle(group.NavigationTitle); -
  • - - @groupLabel - @if (groupBadge == "ns") { ns } - else if (groupBadge == "cmd") { cmd } - else if (groupBadge == "alias") { alias } +
  • + + @item.NavigationTitle + @if (item.Badge is not null) { @item.Badge }
  • } - else if (item is INodeNavigationItem folder) + else { - var g = folder; - var allHidden = folder.NavigationItems.All(n => n.Hidden); - var (folderBadge, folderLabel) = ParseNavTitle(g.NavigationTitle); - - } - else if (item is ILeafNavigationItem leaf) - { - var (leafBadge, leafLabel) = ParseNavTitle(leaf.NavigationTitle); -
  • - - @leafLabel - @if (leafBadge == "ns") { ns } - else if (leafBadge == "cmd") { cmd } - else if (leafBadge == "alias") { alias } - +
  • } } diff --git a/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildService.cs b/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildService.cs index 73b60ff407..5534fd7c98 100644 --- a/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildService.cs +++ b/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildService.cs @@ -116,7 +116,7 @@ Cancel ctx return false; var pathProvider = new GlobalNavigationPathProvider(navigation, assembleSources, assembleContext); - using var htmlWriter = new GlobalNavigationHtmlWriter(logFactory, navigation, collector); + var htmlWriter = new GlobalNavigationHtmlWriter(logFactory, navigation, collector); var legacyPageChecker = new LegacyPageService(logFactory); var historyMapper = new PageLegacyUrlMapper(legacyPageChecker, assembleContext.VersionsConfiguration, assembleSources.LegacyUrlMappings); diff --git a/src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigationHtmlWriter.cs b/src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigationHtmlWriter.cs index 2753e570d5..b3a238bb2a 100644 --- a/src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigationHtmlWriter.cs +++ b/src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigationHtmlWriter.cs @@ -2,7 +2,6 @@ // 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.Concurrent; using Elastic.Documentation; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.Navigation; @@ -13,14 +12,12 @@ namespace Elastic.Documentation.Assembler.Navigation; -public class GlobalNavigationHtmlWriter(ILoggerFactory logFactory, SiteNavigation globalNavigation, IDiagnosticsCollector collector) : INavigationHtmlWriter, IDisposable +public class GlobalNavigationHtmlWriter(ILoggerFactory logFactory, SiteNavigation globalNavigation, IDiagnosticsCollector collector) : INavigationHtmlWriter { private readonly ILogger _logger = logFactory.CreateLogger(); - private readonly SemaphoreSlim _semaphore = new(1, 1); + private readonly NavigationRenderCache _renderedNavigationCache = new(); - private readonly ConcurrentDictionary _renderedNavigationCache = []; - - public async Task RenderNavigation( + public Task RenderNavigation( IRootNavigationItem currentRootNavigation, #pragma warning disable IDE0060 INavigationItem currentNavigationItem, // temporary https://github.com/elastic/docs-content/pull/3730 @@ -29,60 +26,29 @@ public async Task RenderNavigation( ) { if (currentRootNavigation is SiteNavigation) - return NavigationRenderResult.Empty; + return Task.FromResult(NavigationRenderResult.Empty); if (currentRootNavigation.Parent is null or not SiteNavigation) collector.EmitGlobalError($"Passed root is not actually a top level navigation item {currentRootNavigation.NavigationTitle} ({currentRootNavigation.Id}) in {currentRootNavigation.Url}, trying to render: {currentNavigationItem.Url}"); - if (_renderedNavigationCache.TryGetValue(currentRootNavigation.Id, out var html)) - return new NavigationRenderResult { Html = html, Id = currentRootNavigation.Id }; - if (currentRootNavigation is not INodeNavigationItem group) - return NavigationRenderResult.Empty; - - await _semaphore.WaitAsync(ctx); + return Task.FromResult(NavigationRenderResult.Empty); - try + return _renderedNavigationCache.GetOrRenderAsync(currentRootNavigation, () => { - if (_renderedNavigationCache.TryGetValue(currentRootNavigation.Id, out html)) - return new NavigationRenderResult { Html = html, Id = currentRootNavigation.Id }; - _logger.LogInformation("Rendering navigation for {NavigationTitle} ({Id})", currentRootNavigation.NavigationTitle, currentRootNavigation.Id); - - var model = CreateNavigationModel(group); - html = await ((INavigationHtmlWriter)this).Render(model, ctx); - _renderedNavigationCache[currentRootNavigation.Id] = html; - return new NavigationRenderResult - { - Html = html, - Id = currentRootNavigation.Id - }; - } - finally - { - _ = _semaphore.Release(); - } + return ((INavigationHtmlWriter)this).Render(CreateNavigationModel(group), ctx); + }); } - private NavigationViewModel CreateNavigationModel(INodeNavigationItem group) - { - var topLevelItems = globalNavigation.TopLevelItems; - return new NavigationViewModel + private NavigationViewModel CreateNavigationModel(INodeNavigationItem group) => + new() { - Title = group.NavigationTitle, - TitleUrl = group.Url, Tree = group, IsPrimaryNavEnabled = true, IsUsingNavigationDropdown = true, IsGlobalAssemblyBuild = true, - TopLevelItems = topLevelItems, + TopLevelItems = globalNavigation.TopLevelItems, BuildType = BuildType.Assembler }; - } - - public void Dispose() - { - _semaphore.Dispose(); - GC.SuppressFinalize(this); - } } diff --git a/tests-integration/Elastic.Documentation.IntegrationTests/NavigationBuildingTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/NavigationBuildingTests.cs index 50294c095f..2583c9095f 100644 --- a/tests-integration/Elastic.Documentation.IntegrationTests/NavigationBuildingTests.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/NavigationBuildingTests.cs @@ -77,16 +77,14 @@ public async Task AssertRealNavigation() root.Parent.Should().BeOfType(); }*/ - var slice = _TocTree.Create(new NavigationViewModel + var slice = _TocTree.Create(NavigationRenderModel.Create(new NavigationViewModel { - Title = "X", IsGlobalAssemblyBuild = true, IsPrimaryNavEnabled = true, Tree = navigation, TopLevelItems = navigation.TopLevelItems, - TitleUrl = navigation.Index.Url, IsUsingNavigationDropdown = true - }); + })); var html = await slice.RenderAsync(cancellationToken: ctx); var context = BrowsingContext.New(); var document = await context.OpenAsync(req => req.Content(html), ctx); @@ -138,23 +136,19 @@ private static void RecurseNav(INodeNavigationItem - /// Recursively extracts all URLs from the navigation tree, following the same logic as the Razor templates. - /// Excludes hidden items and parent index items (to match _TocTreeNav.cshtml logic). + /// Recursively extracts all URLs from the navigation tree, mirroring the skip rules + /// applies when mapping the domain tree for rendering. ///
    private static IEnumerable GetAllNavigationUrls(INavigationItem item) { - // Skip hidden items (matches _TocTreeNav.cshtml line 9-12) if (item.Hidden) yield break; - // Skip if this item is its parent's index (matches _TocTreeNav.cshtml line 14-16) if (item.Parent is not null && item.Parent.Index == item) yield break; - // Yield the current item's URL yield return item.Url; - // Recursively process children if this is a node if (item is not INodeNavigationItem node) yield break; diff --git a/tests/Navigation.Tests/Assembler/ComplexSiteNavigationTests.cs b/tests/Navigation.Tests/Assembler/ComplexSiteNavigationTests.cs index b1b1b1eb97..508b09cd2e 100644 --- a/tests/Navigation.Tests/Assembler/ComplexSiteNavigationTests.cs +++ b/tests/Navigation.Tests/Assembler/ComplexSiteNavigationTests.cs @@ -3,17 +3,66 @@ // See the LICENSE file in the project root for more information using AwesomeAssertions; +using Elastic.Documentation.Assembler.Navigation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Toc; using Elastic.Documentation.Navigation.Assembler; using Elastic.Documentation.Navigation.Isolated; using Elastic.Documentation.Navigation.Isolated.Leaf; using Elastic.Documentation.Navigation.Isolated.Node; +using Microsoft.Extensions.Logging.Abstractions; namespace Elastic.Documentation.Navigation.Tests.Assembler; public class ComplexSiteNavigationTests(ITestOutputHelper output) { + [Fact] + public async Task MultipleSectionsFromSameRepository_UseContentHashesAndCacheByRoot() + { + // language=yaml + var siteNavYaml = """ + toc: + - toc: observability:// + path_prefix: / + - toc: platform://deployment-guide + path_prefix: /deployment + - toc: platform://cloud-guide + path_prefix: /cloud + """; + var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); + var documentationSets = new List(); + foreach (var repository in new[] { "observability", "platform" }) + { + var repositoryPath = $"/checkouts/current/{repository}"; + var context = SiteNavigationTestFixture.CreateAssemblerContext(fileSystem, repositoryPath, output); + var docset = DocumentationSetFile.LoadAndResolve( + context.Collector, + fileSystem.FileInfo.New($"{repositoryPath}/docs/docset.yml"), + FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + documentationSets.Add( + new DocumentationSetNavigation(docset, context, GenericDocumentationFileFactory.Instance)); + } + + var siteContext = SiteNavigationTestFixture.CreateAssemblerContext( + fileSystem, "/checkouts/current/observability", output); + var siteNavigation = new SiteNavigation( + SiteNavigationFile.Deserialize(siteNavYaml), siteContext, documentationSets, sitePrefix: null); + var sections = siteNavigation.NavigationItems + .OfType>() + .Where(section => section.Identifier.Scheme == "platform") + .ToArray(); + sections.Should().HaveCount(2); + + var writer = new GlobalNavigationHtmlWriter( + NullLoggerFactory.Instance, siteNavigation, siteContext.Collector); + var first = await writer.RenderNavigation(sections[0], sections[0].Index, TestContext.Current.CancellationToken); + var second = await writer.RenderNavigation(sections[1], sections[1].Index, TestContext.Current.CancellationToken); + var firstAgain = await writer.RenderNavigation(sections[0], sections[0].Index, TestContext.Current.CancellationToken); + + first.Id.Should().NotBe(second.Id); + firstAgain.Should().BeSameAs(first); + } + [Fact] public void ComplexNavigationWithMultipleNestedTocsAppliesPathPrefixToRootUrls() { diff --git a/tests/Navigation.Tests/Codex/CodexNavigationRenderingTests.cs b/tests/Navigation.Tests/Codex/CodexNavigationRenderingTests.cs index 80ca761dae..28ad48d69e 100644 --- a/tests/Navigation.Tests/Codex/CodexNavigationRenderingTests.cs +++ b/tests/Navigation.Tests/Codex/CodexNavigationRenderingTests.cs @@ -4,8 +4,11 @@ using AwesomeAssertions; using Elastic.Codex.Navigation; +using Elastic.Documentation; using Elastic.Documentation.Configuration.Codex; using Elastic.Documentation.Navigation.Isolated.Node; +using Elastic.Documentation.Site.Navigation; +using RazorSlices; namespace Elastic.Documentation.Navigation.Tests.Codex; @@ -17,6 +20,25 @@ namespace Elastic.Documentation.Navigation.Tests.Codex; /// public class CodexNavigationRenderingTests(ITestOutputHelper output) : CodexNavigationTestBase(output) { + [Fact] + public async Task ProjectlessRepositories_DifferentTrees_ProduceDifferentContentHashes() + { + var docSetNavigations = CreateMockDocSetNavigations(["codex-environments", "ml-team"], includeProject: false); + var first = docSetNavigations["codex-environments"] + .Should().BeAssignableTo>().Subject; + var second = docSetNavigations["ml-team"] + .Should().BeAssignableTo>().Subject; + + first.Id.Should().Be(second.Id); + + var firstResult = await RenderNavigation(first); + var secondResult = await RenderNavigation(second); + + firstResult.Id.Should().NotBe(secondResult.Id); + firstResult.Html.Should().Contain("codex-environments"); + secondResult.Html.Should().Contain("ml-team"); + } + [Fact] public void GroupNavigation_TopLevelItems_ContainsAllGroupMembers() { @@ -212,4 +234,25 @@ public void GroupLandingPage_HasAllMembersAsNavigationItems() groupNav.Index.Url.Should().Be("/g/tools"); groupNav.Index.NavigationTitle.Should().Be("Tools"); } + + private static async Task RenderNavigation( + IRootNavigationItem navigation) + { + var model = new NavigationViewModel + { + Tree = navigation, + IsPrimaryNavEnabled = false, + IsGlobalAssemblyBuild = false, + TopLevelItems = navigation.NavigationItems.OfType>(), + IsUsingNavigationDropdown = false, + BuildType = BuildType.Codex + }; + var renderModel = NavigationRenderModel.Create(model); + var html = await _TocTree.Create(renderModel).RenderAsync(cancellationToken: TestContext.Current.CancellationToken); + return new NavigationRenderResult + { + Html = html, + Id = renderModel.ContentHash + }; + } } diff --git a/tests/Navigation.Tests/Codex/CodexNavigationTestBase.cs b/tests/Navigation.Tests/Codex/CodexNavigationTestBase.cs index fc346ce03a..ea50bacdde 100644 --- a/tests/Navigation.Tests/Codex/CodexNavigationTestBase.cs +++ b/tests/Navigation.Tests/Codex/CodexNavigationTestBase.cs @@ -29,14 +29,15 @@ protected static CodexConfiguration CreateCodexConfiguration(string sitePrefix) }; protected IReadOnlyDictionary CreateMockDocSetNavigations( - IEnumerable repoNames) + IEnumerable repoNames, + bool includeProject = true) { var result = new Dictionary(); var fileSystem = new MockFileSystem(); foreach (var repoName in repoNames) { - var docSet = CreateMockDocumentationSet(fileSystem, repoName); + var docSet = CreateMockDocumentationSet(fileSystem, repoName, includeProject); var context = new TestDocumentationSetContext( fileSystem, fileSystem.DirectoryInfo.New($"/{repoName}/docs"), @@ -54,18 +55,16 @@ protected IReadOnlyDictionary CreateMockDoc return result; } - private static DocumentationSetFile CreateMockDocumentationSet(MockFileSystem fileSystem, string repoName) + private static DocumentationSetFile CreateMockDocumentationSet(MockFileSystem fileSystem, string repoName, bool includeProject) { var docsPath = $"/{repoName}/docs"; fileSystem.AddDirectory(docsPath); fileSystem.AddFile($"{docsPath}/index.md", new MockFileData($"# {repoName}")); // language=yaml - var yaml = $""" - project: '{repoName}' - toc: - - file: index.md - """; + var yaml = includeProject + ? $"project: '{repoName}'\ntoc:\n - file: index.md" + : "toc:\n - file: index.md"; return DocumentationSetFile.LoadAndResolve( new DiagnosticsCollector([]), diff --git a/tests/Navigation.Tests/Navigation.Tests.csproj b/tests/Navigation.Tests/Navigation.Tests.csproj index 8d5c0bca73..7efb6503c1 100644 --- a/tests/Navigation.Tests/Navigation.Tests.csproj +++ b/tests/Navigation.Tests/Navigation.Tests.csproj @@ -12,6 +12,7 @@ + diff --git a/tests/Navigation.Tests/Rendering/NavigationRenderModelTests.cs b/tests/Navigation.Tests/Rendering/NavigationRenderModelTests.cs new file mode 100644 index 0000000000..53b9f6b7cd --- /dev/null +++ b/tests/Navigation.Tests/Rendering/NavigationRenderModelTests.cs @@ -0,0 +1,163 @@ +// 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.TestingHelpers; +using AwesomeAssertions; +using Elastic.Documentation.Configuration.Toc; +using Elastic.Documentation.Extensions; +using Elastic.Documentation.Navigation.Isolated.Node; +using Elastic.Documentation.Navigation.Tests.Isolation; +using Elastic.Documentation.Site.Navigation; + +namespace Elastic.Documentation.Navigation.Tests.Rendering; + +public class NavigationRenderModelTests(ITestOutputHelper output) : DocumentationSetNavigationTestBase(output) +{ + [Fact] + public void EquivalentTrees_ProduceSameContentHash() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - file: index.md + - folder: setup + children: + - file: index.md + - file: install.md + """; + + var first = CreateRenderModel(yaml); + var second = CreateRenderModel(yaml); + + first.ContentHash.Should().Be(second.ContentHash); + } + + [Fact] + public void DifferentPages_ProduceDifferentContentHashes() + { + // language=yaml + var first = CreateRenderModel(""" + project: 'test-project' + toc: + - file: index.md + - file: overview.md + """); + // language=yaml + var second = CreateRenderModel(""" + project: 'test-project' + toc: + - file: index.md + - file: reference.md + """); + + first.ContentHash.Should().NotBe(second.ContentHash); + } + + [Fact] + public void ReorderedSiblings_ProduceDifferentContentHashes() + { + // language=yaml + var first = CreateRenderModel(""" + project: 'test-project' + toc: + - file: index.md + - file: alpha.md + - file: beta.md + """); + // language=yaml + var second = CreateRenderModel(""" + project: 'test-project' + toc: + - file: index.md + - file: beta.md + - file: alpha.md + """); + + first.ContentHash.Should().NotBe(second.ContentHash); + } + + [Fact] + public void HiddenItems_AreExcludedFromTheTree_AndChangeTheContentHash() + { + // language=yaml + var visible = CreateRenderModel(""" + project: 'test-project' + toc: + - file: index.md + - file: guide.md + - file: secret.md + """); + // language=yaml + var hidden = CreateRenderModel(""" + project: 'test-project' + toc: + - file: index.md + - file: guide.md + - hidden: secret.md + """); + + visible.Tree.Should().Contain(n => n.Url == "/secret"); + hidden.Tree.Should().NotContain(n => n.Url == "/secret"); + hidden.ContentHash.Should().NotBe(visible.ContentHash); + } + + [Fact] + public void PrimaryNav_OmitsIndexRow_AndChangesTheContentHash() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - file: index.md + - file: guide.md + """; + + var withIndexRow = CreateRenderModel(yaml); + var withoutIndexRow = CreateRenderModel(yaml, isPrimaryNavEnabled: true); + + withIndexRow.Tree[0].Kind.Should().Be(NavigationRenderNodeKind.IndexLink); + withoutIndexRow.Tree.Should().NotContain(n => n.Kind == NavigationRenderNodeKind.IndexLink); + withoutIndexRow.ContentHash.Should().NotBe(withIndexRow.ContentHash); + } + + [Fact] + public void Folders_CarryToggleStateAndNavigationItems() + { + // language=yaml + var model = CreateRenderModel(""" + project: 'test-project' + toc: + - file: index.md + - folder: setup + children: + - file: index.md + - file: install.md + """); + + var folder = model.Tree.Should().ContainSingle(n => n.Kind == NavigationRenderNodeKind.Folder).Subject; + folder.Url.Should().Be("/setup"); + folder.Id.Should().NotBeNullOrEmpty(); + folder.ShowToggle.Should().BeTrue(); + folder.NavigationItems.Should().ContainSingle(n => n.Url == "/setup/install"); + } + + private NavigationRenderModel CreateRenderModel(string yaml, bool isPrimaryNavEnabled = false) + { + var fileSystem = new MockFileSystem(); + fileSystem.AddDirectory("/docs"); + var context = CreateContext(fileSystem); + var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, yaml, fileSystem.NewDirInfo("docs")); + var navigation = new DocumentationSetNavigation(docSet, context, TestDocumentationFileFactory.Instance); + return NavigationRenderModel.Create(new NavigationViewModel + { + Tree = navigation, + IsPrimaryNavEnabled = isPrimaryNavEnabled, + IsGlobalAssemblyBuild = false, + TopLevelItems = navigation.NavigationItems.OfType>().ToList(), + IsUsingNavigationDropdown = false, + BuildType = BuildType.Isolated + }); + } +}