Skip to content

Optimize lazy load navigation #1514

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Jul 7, 2025
1 change: 1 addition & 0 deletions src/Elastic.ApiExplorer/ApiViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ public GlobalLayoutViewModel CreateGlobalLayoutModel() =>
Previous = null,
Next = null,
NavigationHtml = NavigationHtml,
NavigationFileName = string.Empty,
UrlPathPrefix = BuildContext.UrlPathPrefix,
AllowIndexing = BuildContext.AllowIndexing,
CanonicalBaseUrl = BuildContext.CanonicalBaseUrl,
Expand Down
4 changes: 2 additions & 2 deletions src/Elastic.ApiExplorer/OpenApiGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -288,11 +288,11 @@ private async Task<IFileInfo> Render<T>(INavigationItem current, T page, ApiRend
if (!outputFile.Directory!.Exists)
outputFile.Directory.Create();

var navigationHtml = await navigationRenderer.RenderNavigation(current.NavigationRoot, new Uri("http://ignored.example"), INavigationHtmlWriter.AllLevels, ctx);
var navigationRenderResult = await navigationRenderer.RenderNavigation(current.NavigationRoot, new Uri("http://ignored.example"), INavigationHtmlWriter.AllLevels, ctx);
renderContext = renderContext with
{
CurrentNavigation = current,
NavigationHtml = navigationHtml
NavigationHtml = navigationRenderResult.Html
};
await using var stream = _writeFileSystem.FileStream.New(outputFile.FullName, FileMode.OpenOrCreate);
await page.RenderAsync(stream, renderContext, ctx);
Expand Down
14 changes: 11 additions & 3 deletions src/Elastic.Documentation.Site/Assets/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,23 @@ import { $, $$ } from 'select-dom'
import { UAParser } from 'ua-parser-js'

const { getOS } = new UAParser()
const isLazyLoadNavigationEnabled =
$('meta[property="docs:feature:lazy-load-navigation"]')?.content === 'true'

document.addEventListener('htmx:load', function (event) {
console.log('htmx:load')
console.log(event.detail)
initTocNav()
initHighlight()
initCopyButton()
initTabs()
initNav()

// We do this so that the navigation is not initialized twice
if (isLazyLoadNavigationEnabled) {
if (event.detail.elt.id === 'nav-tree') {
initNav()
}
} else {
initNav()
}
initSmoothScroll()
openDetailsWithAnchor()
initDismissibleBanner()
Expand Down
1 change: 1 addition & 0 deletions src/Elastic.Documentation.Site/Layout/_Head.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,4 @@
{
<meta property="og:url" content="@Model.CanonicalUrl" />
}
<meta property="docs:feature:lazy-load-navigation" content="@Model.Features.LazyLoadNavigation" />
4 changes: 2 additions & 2 deletions src/Elastic.Documentation.Site/Layout/_PagesNav.cshtml
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
@inherits RazorSlice<Elastic.Documentation.Site.GlobalLayoutViewModel>
<aside class="sidebar bg-white fixed md:sticky shadow-2xl md:shadow-none left-[100%] group-has-[#pages-nav-hamburger:checked]/body:left-0 bottom-0 md:left-auto pl-6 md:pl-2 top-[calc(var(--offset-top)+1px)] w-[80%] md:w-auto shrink-0 border-r-1 border-r-grey-20 z-40 md:z-auto">

@if (Model.Features.LazyLoadNavigation)
@if (Model.Features.LazyLoadNavigation && !string.IsNullOrEmpty(Model.NavigationFileName))
{
<div hx-get="@(Model.CurrentNavigationItem.Url + (Model.CurrentNavigationItem.Url.EndsWith('/') ? "index.nav.html" : "/index.nav.html"))" hx-trigger="load" hx-params="nav" hx-push-url="false" hx-swap="innerHTML" hx-target="#pages-nav"></div>
<div class="hidden" hx-get="@(Model.Link(Model.NavigationFileName))" hx-trigger="load" hx-params="nav" hx-push-url="false" hx-swap="innerHTML" hx-target="#pages-nav"></div>
}
<nav
id="pages-nav"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,23 @@ public interface INavigationHtmlWriter
{
const int AllLevels = -1;

Task<string> RenderNavigation(IRootNavigationItem<INavigationModel, INavigationItem> currentRootNavigation, Uri navigationSource, int maxLevel, Cancel ctx = default);
Task<NavigationRenderResult> RenderNavigation(IRootNavigationItem<INavigationModel, INavigationItem> currentRootNavigation, Uri navigationSource,
int maxLevel, Cancel ctx = default);

async Task<string> Render(NavigationViewModel model, Cancel ctx)
{
var slice = _TocTree.Create(model);
return await slice.RenderAsync(cancellationToken: ctx);
}
}
public record NavigationRenderResult
{
public static NavigationRenderResult Empty { get; } = new()
{
Html = string.Empty,
Id = "empty-navigation" // random id
};

public required string Html { get; init; }
public required string Id { get; init; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

using System.Collections.Concurrent;
using Elastic.Documentation.Configuration;
using Elastic.Documentation.Extensions;

namespace Elastic.Documentation.Site.Navigation;

Expand All @@ -12,19 +13,29 @@ public class IsolatedBuildNavigationHtmlWriter(BuildContext context, IRootNaviga
{
private readonly ConcurrentDictionary<(string, int), string> _renderedNavigationCache = [];

public async Task<string> RenderNavigation(IRootNavigationItem<INavigationModel, INavigationItem> currentRootNavigation, Uri navigationSource, int maxLevel, Cancel ctx = default)
public async Task<NavigationRenderResult> RenderNavigation(IRootNavigationItem<INavigationModel, INavigationItem> currentRootNavigation,
Uri navigationSource, int maxLevel, Cancel ctx = default)
{
var navigation = context.Configuration.Features.PrimaryNavEnabled || currentRootNavigation.IsUsingNavigationDropdown
? currentRootNavigation
: siteRoot;

var id = ShortId.Create($"{(navigation.Id, maxLevel).GetHashCode()}");
if (_renderedNavigationCache.TryGetValue((navigation.Id, maxLevel), out var value))
return value;

{
return new NavigationRenderResult
{
Html = value,
Id = id
};
}
var model = CreateNavigationModel(navigation, maxLevel);
value = await ((INavigationHtmlWriter)this).Render(model, ctx);
_renderedNavigationCache[(navigation.Id, maxLevel)] = value;
return value;
return new NavigationRenderResult
{
Html = value,
Id = id
};
}

private NavigationViewModel CreateNavigationModel(IRootNavigationItem<INavigationModel, INavigationItem> navigation, int maxLevel) =>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
@using Elastic.Documentation.Site.Navigation
@inherits RazorSlice<Elastic.Documentation.Site.Navigation.NavigationViewModel>

<div class="pb-20 font-body">
<div class="pb-20 font-body" id="nav-tree">
@{
var currentTopLevelItem = Model.TopLevelItems.FirstOrDefault(i => i.Id == Model.Tree.Id) ?? Model.Tree;
}
Expand Down
1 change: 1 addition & 0 deletions src/Elastic.Documentation.Site/_ViewModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ public record GlobalLayoutViewModel
public required INavigationItem? Next { get; init; }

public required string NavigationHtml { get; init; }
public required string NavigationFileName { get; init; }
public required string? UrlPathPrefix { get; init; }
public required Uri? CanonicalBaseUrl { get; init; }
public string? CanonicalUrl => CanonicalBaseUrl is not null ?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ public class DocumentationFileExporter(IFileSystem readFileSystem, IFileSystem w
public override async ValueTask ProcessFile(ProcessingFileContext context, Cancel ctx)
{
if (context.File is MarkdownFile markdown)
context.MarkdownDocument = await context.HtmlWriter.WriteAsync(context.OutputFile, markdown, context.ConversionCollector, ctx);
context.MarkdownDocument = await context.HtmlWriter.WriteAsync(context.BuildContext.DocumentationOutputDirectory, context.OutputFile, markdown, context.ConversionCollector, ctx);
else
{
if (context.OutputFile.Directory is { Exists: false })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@ public override async ValueTask ProcessFile(ProcessingFileContext context, Cance
switch (context.File)
{
case DetectionRuleFile df:
context.MarkdownDocument = await htmlWriter.WriteAsync(DetectionRuleFile.OutputPath(outputFile, context.BuildContext), df, conversionCollector, ctx);
context.MarkdownDocument = await htmlWriter.WriteAsync(context.BuildContext.DocumentationOutputDirectory, DetectionRuleFile.OutputPath(outputFile, context.BuildContext), df, conversionCollector, ctx);
break;
case MarkdownFile markdown:
context.MarkdownDocument = await htmlWriter.WriteAsync(outputFile, markdown, conversionCollector, ctx);
context.MarkdownDocument = await htmlWriter.WriteAsync(context.BuildContext.DocumentationOutputDirectory, outputFile, markdown, conversionCollector, ctx);
break;
default:
if (outputFile.Directory is { Exists: false })
Expand Down
41 changes: 30 additions & 11 deletions src/Elastic.Markdown/HtmlWriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using System.IO.Abstractions;
using Elastic.Documentation;
using Elastic.Documentation.Configuration.Builder;
using Elastic.Documentation.Extensions;
using Elastic.Documentation.Legacy;
using Elastic.Documentation.Site.FileProviders;
using Elastic.Documentation.Site.Navigation;
Expand Down Expand Up @@ -55,10 +56,12 @@ private async Task<RenderResult> RenderLayout(MarkdownFile markdown, MarkdownDoc
var html = MarkdownFile.CreateHtml(document);
await DocumentationSet.Tree.Resolve(ctx);

var fullNavigationHtml = await NavigationHtmlWriter.RenderNavigation(markdown.NavigationRoot, markdown.NavigationSource, INavigationHtmlWriter.AllLevels, ctx);
var miniNavigationHtml = DocumentationSet.Context.Configuration.Features.LazyLoadNavigation
? await NavigationHtmlWriter.RenderNavigation(markdown.NavigationRoot, markdown.NavigationSource, 1, ctx)
: "lazy navigation feature disabled";
var fullNavigationRenderResult = await NavigationHtmlWriter.RenderNavigation(markdown.NavigationRoot, markdown.NavigationSource, INavigationHtmlWriter.AllLevels, ctx);
var miniNavigationRenderResult = await NavigationHtmlWriter.RenderNavigation(markdown.NavigationRoot, markdown.NavigationSource, 1, ctx);

var navigationHtmlRenderResult = DocumentationSet.Context.Configuration.Features.LazyLoadNavigation
? miniNavigationRenderResult
: fullNavigationRenderResult;

var current = PositionalNavigation.GetCurrent(markdown);
var previous = PositionalNavigation.GetPrevious(markdown);
Expand Down Expand Up @@ -103,6 +106,15 @@ private async Task<RenderResult> RenderLayout(MarkdownFile markdown, MarkdownDoc
if (PositionalNavigation.MarkdownNavigationLookup.TryGetValue("docs-content://versions.md", out var item))
allVersionsUrl = item.Url;


var navigationFileName = $"{fullNavigationRenderResult.Id}.nav.html";

_ = DocumentationSet.NavigationRenderResults.TryAdd(
fullNavigationRenderResult.Id,
fullNavigationRenderResult
);


var slice = Page.Index.Create(new IndexViewModel
{
SiteName = siteName,
Expand All @@ -118,7 +130,8 @@ private async Task<RenderResult> RenderLayout(MarkdownFile markdown, MarkdownDoc
PreviousDocument = previous,
NextDocument = next,
Parents = parents,
NavigationHtml = DocumentationSet.Configuration.Features.LazyLoadNavigation ? miniNavigationHtml : fullNavigationHtml,
NavigationHtml = navigationHtmlRenderResult.Html,
NavigationFileName = navigationFileName,
UrlPathPrefix = markdown.UrlPathPrefix,
AppliesTo = markdown.YamlFrontMatter?.AppliesTo,
GithubEditUrl = editUrl,
Expand All @@ -135,15 +148,17 @@ private async Task<RenderResult> RenderLayout(MarkdownFile markdown, MarkdownDoc
Products = allProducts,
VersionsConfig = DocumentationSet.Context.VersionsConfig
});

return new RenderResult
{
Html = await slice.RenderAsync(cancellationToken: ctx),
FullNavigationPartialHtml = fullNavigationHtml
FullNavigationPartialHtml = fullNavigationRenderResult.Html,
NavigationFileName = navigationFileName
};

}

public async Task<MarkdownDocument> WriteAsync(IFileInfo outputFile, MarkdownFile markdown, IConversionCollector? collector, Cancel ctx = default)
public async Task<MarkdownDocument> WriteAsync(IDirectoryInfo outBaseDir, IFileInfo outputFile, MarkdownFile markdown, IConversionCollector? collector, Cancel ctx = default)
{
if (outputFile.Directory is { Exists: false })
outputFile.Directory.Create();
Expand Down Expand Up @@ -171,10 +186,12 @@ public async Task<MarkdownDocument> WriteAsync(IFileInfo outputFile, MarkdownFil
collector?.Collect(markdown, document, rendered.Html);
await writeFileSystem.File.WriteAllTextAsync(path, rendered.Html, ctx);

if (DocumentationSet.Configuration.Features.LazyLoadNavigation)
{
await writeFileSystem.File.WriteAllTextAsync(path.Replace(".html", ".nav.html"), rendered.FullNavigationPartialHtml, ctx);
}
if (!DocumentationSet.Configuration.Features.LazyLoadNavigation)
return document;

var navFilePath = Path.Combine(outBaseDir.FullName, rendered.NavigationFileName);
if (!writeFileSystem.File.Exists(navFilePath))
await writeFileSystem.File.WriteAllTextAsync(navFilePath, rendered.FullNavigationPartialHtml, ctx);
return document;
}

Expand All @@ -184,4 +201,6 @@ public record RenderResult
{
public required string Html { get; init; }
public required string FullNavigationPartialHtml { get; init; }
public required string NavigationFileName { get; init; }

}
3 changes: 3 additions & 0 deletions src/Elastic.Markdown/IO/DocumentationSet.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// 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 System.Collections.Frozen;
using System.IO.Abstractions;
using System.Runtime.InteropServices;
Expand Down Expand Up @@ -129,6 +130,8 @@ public class DocumentationSet : INavigationLookups, IPositionalNavigation

public IReadOnlyCollection<IDocsBuilderExtension> EnabledExtensions { get; }

public ConcurrentDictionary<string, NavigationRenderResult> NavigationRenderResults { get; } = [];

public DocumentationSet(
BuildContext context,
ILoggerFactory logger,
Expand Down
1 change: 1 addition & 0 deletions src/Elastic.Markdown/Page/Index.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
Next = Model.NextDocument,
Parents = Model.Parents,
NavigationHtml = Model.NavigationHtml,
NavigationFileName = Model.NavigationFileName,
UrlPathPrefix = Model.UrlPathPrefix,
GithubEditUrl = Model.GithubEditUrl,
AllowIndexing = Model.AllowIndexing,
Expand Down
2 changes: 2 additions & 0 deletions src/Elastic.Markdown/Page/IndexViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ public class IndexViewModel
public required INavigationItem[] Parents { get; init; }

public required string NavigationHtml { get; init; }

public required string NavigationFileName { get; init; }
public required string CurrentVersion { get; init; }

public required string? AllVersionsUrl { get; init; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
using Elastic.Documentation.Extensions;
using Elastic.Documentation.Site.Navigation;
using Elastic.Markdown.IO.Navigation;

Expand Down Expand Up @@ -44,33 +45,36 @@ private bool TryGetNavigationRoot(
return true;
}

public async Task<string> RenderNavigation(IRootNavigationItem<INavigationModel, INavigationItem> currentRootNavigation, Uri navigationSource, int maxLevel = -1, Cancel ctx = default)
public async Task<NavigationRenderResult> RenderNavigation(IRootNavigationItem<INavigationModel, INavigationItem> currentRootNavigation,
Uri navigationSource, int maxLevel, Cancel ctx = default)
{
if (Phantoms.Contains(navigationSource))
return string.Empty;
if (Phantoms.Contains(navigationSource)
|| !TryGetNavigationRoot(navigationSource, out var navigationRoot, out var navigationRootSource)
|| Phantoms.Contains(navigationRootSource)
)
return NavigationRenderResult.Empty;

if (!TryGetNavigationRoot(navigationSource, out var navigationRoot, out var navigationRootSource))
return string.Empty;

if (Phantoms.Contains(navigationRootSource))
return string.Empty;
var navigationId = ShortId.Create($"{(navigationRootSource, maxLevel).GetHashCode()}");

if (_renderedNavigationCache.TryGetValue((navigationRootSource, maxLevel), out var value))
return value;
return NavigationRenderResult.Empty;

if (navigationRootSource == new Uri("docs-content:///"))
{
_renderedNavigationCache[(navigationRootSource, maxLevel)] = string.Empty;
return string.Empty;
return NavigationRenderResult.Empty;
}

Console.WriteLine($"Rendering navigation for {navigationRootSource}");

var model = CreateNavigationModel(navigationRoot, maxLevel);
value = await ((INavigationHtmlWriter)this).Render(model, ctx);
_renderedNavigationCache[(navigationRootSource, maxLevel)] = value;

return value;
return new NavigationRenderResult
{
Html = value,
Id = navigationId
};
}

private NavigationViewModel CreateNavigationModel(DocumentationGroup group, int maxLevel)
Expand Down
18 changes: 12 additions & 6 deletions src/tooling/docs-builder/Http/DocumentationWebHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -180,14 +180,20 @@ private async Task<IResult> ServeApiFile(ReloadableGeneratorState holder, string
private static async Task<IResult> ServeDocumentationFile(ReloadableGeneratorState holder, string slug, Cancel ctx)
{
var generator = holder.Generator;
const string navPartialSuffix = "index.nav.html";
var isNavPartial = slug.EndsWith(navPartialSuffix);
const string navPartialSuffix = ".nav.html";
if (slug.EndsWith(navPartialSuffix))
{
var segments = slug.Split("/");
var lastSegment = segments.Last();
var navigationId = lastSegment.Replace(navPartialSuffix, "");
return generator.DocumentationSet.NavigationRenderResults.TryGetValue(navigationId, out var rendered)
? Results.Content(rendered.Html, "text/html")
: Results.NotFound();
}

if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
slug = slug.Replace('/', Path.DirectorySeparatorChar);

if (isNavPartial)
slug = slug.Replace(navPartialSuffix, "index.md");

var s = Path.GetExtension(slug) == string.Empty ? Path.Combine(slug, "index.md") : slug;

if (!generator.DocumentationSet.FlatMappedFiles.TryGetValue(s, out var documentationFile))
Expand All @@ -207,7 +213,7 @@ private static async Task<IResult> ServeDocumentationFile(ReloadableGeneratorSta
{
case MarkdownFile markdown:
var rendered = await generator.RenderLayout(markdown, ctx);
return Results.Content(isNavPartial ? rendered.FullNavigationPartialHtml : rendered.Html, "text/html");
return Results.Content(rendered.Html, "text/html");

case ImageFile image:
return Results.File(image.SourceFile.FullName, image.MimeType);
Expand Down
Loading