Skip to content

Commit 38d9c83

Browse files
theletterfcodexcursoragent
committed
fix(nav-v2): resolve section roots and island back links
Register section root URLs as authoritative owners and normalize site-prefixed lookups so section landing pages keep their own sidebar. Make island back links preserve the active site prefix, including nested reference islands such as Logstash plugins. Co-Authored-By: GPT-5.5 <noreply@openai.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent dfa1629 commit 38d9c83

2 files changed

Lines changed: 77 additions & 16 deletions

File tree

src/Elastic.Documentation.Navigation/V2/SiteNavigationV2.cs

Lines changed: 54 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -82,17 +82,32 @@ public SiteNavigationV2(
8282
/// </summary>
8383
public NavigationSection? GetSectionForUrl(string? pageUrl)
8484
{
85-
if (pageUrl is not null)
86-
{
87-
var normalized = pageUrl.TrimEnd('/');
88-
if (_urlToSection.TryGetValue(normalized, out var section))
89-
return section;
90-
if (_urlToSection.TryGetValue(normalized + "/", out section))
91-
return section;
92-
}
85+
if (pageUrl is not null && TryGetSectionForUrl(pageUrl, out var section))
86+
return section;
9387
return Sections.FirstOrDefault(s => !s.Isolated);
9488
}
9589

90+
private bool TryGetSectionForUrl(string url, out NavigationSection section)
91+
{
92+
var normalized = url.TrimEnd('/');
93+
if (_urlToSection.TryGetValue(normalized, out section!))
94+
return true;
95+
if (_urlToSection.TryGetValue(normalized + "/", out section!))
96+
return true;
97+
98+
var prefix = Url.TrimEnd('/');
99+
if (!string.IsNullOrEmpty(prefix) && normalized.StartsWith($"{prefix}/", StringComparison.OrdinalIgnoreCase))
100+
{
101+
var withoutPrefix = normalized[prefix.Length..];
102+
if (_urlToSection.TryGetValue(withoutPrefix, out section!))
103+
return true;
104+
if (_urlToSection.TryGetValue(withoutPrefix + "/", out section!))
105+
return true;
106+
}
107+
108+
section = null!;
109+
return false;
110+
}
96111
private static IReadOnlyList<NavigationSection> BuildSections(IReadOnlyList<INavigationItem> items) =>
97112
items
98113
.OfType<SectionNavigationNode>()
@@ -137,7 +152,10 @@ [.. islandNode.NavigationItems]
137152
private void BuildUrlToSectionLookup()
138153
{
139154
foreach (var section in Sections)
155+
{
140156
CollectUrlsForSection(section.NavigationItems, section);
157+
AddUrlToSection(section.Url, section, replaceExisting: true);
158+
}
141159
}
142160

143161
private void CollectUrlsForSection(IEnumerable<INavigationItem> items, NavigationSection section)
@@ -148,17 +166,40 @@ private void CollectUrlsForSection(IEnumerable<INavigationItem> items, Navigatio
148166
if (item is IslandNavigationNode)
149167
continue;
150168

151-
if (!string.IsNullOrEmpty(item.Url))
152-
{
153-
var normalized = item.Url.TrimEnd('/');
154-
_ = _urlToSection.TryAdd(normalized, section);
155-
}
169+
AddUrlToSection(item.Url, section);
156170

157171
if (item is INodeNavigationItem<INavigationModel, INavigationItem> node)
158172
CollectUrlsForSection(node.NavigationItems, section);
159173
}
160174
}
161175

176+
private void AddUrlToSection(string url, NavigationSection section, bool replaceExisting = false)
177+
{
178+
if (string.IsNullOrEmpty(url))
179+
return;
180+
AddNormalizedUrlToSection(url, section, replaceExisting);
181+
if (IsExternalUrl(url))
182+
return;
183+
var prefix = Url.TrimEnd('/');
184+
var path = url.TrimStart('/');
185+
var prefixed = string.IsNullOrEmpty(path) ? prefix : $"{prefix}/{path}";
186+
if (!url.Equals(prefixed, StringComparison.OrdinalIgnoreCase))
187+
AddNormalizedUrlToSection(prefixed, section, replaceExisting);
188+
}
189+
190+
private void AddNormalizedUrlToSection(string url, NavigationSection section, bool replaceExisting)
191+
{
192+
var normalized = url.TrimEnd('/');
193+
if (replaceExisting)
194+
_urlToSection[normalized] = section;
195+
else
196+
_ = _urlToSection.TryAdd(normalized, section);
197+
}
198+
199+
private static bool IsExternalUrl(string url) =>
200+
url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
201+
url.StartsWith("https://", StringComparison.OrdinalIgnoreCase);
202+
162203
private void BuildTocRootToIslandLookup()
163204
{
164205
foreach (var island in Islands)

src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigationHtmlWriter.cs

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,8 @@ Cancel ctx
138138
return CreateIslandResult(cachedHtml, island, navV2);
139139

140140
_logger.LogInformation("Rendering V2 island navigation: {IslandLabel} ({IslandId})", island.Label, island.Id);
141+
var islandUrlForPrefix = island.NavigationItems.FirstOrDefault(i => !string.IsNullOrEmpty(i.Url))?.Url ?? island.Url;
142+
var backArrowUrl = CombineWithSitePrefix(navV2, island.BackUrl, islandUrlForPrefix);
141143

142144
var wrapper = new SectionNavigationV2Wrapper(
143145
new NavigationSection(island.Id, island.Label, "", false, island.NavigationItems),
@@ -157,7 +159,7 @@ Cancel ctx
157159
IsNavV2 = true,
158160
IsIsolatedSection = true,
159161
SectionUrl = null,
160-
BackArrowUrl = CombineWithSitePrefix(navV2, island.BackUrl)
162+
BackArrowUrl = backArrowUrl
161163
};
162164

163165
var html = await ((INavigationHtmlWriter)this).Render(model, ctx);
@@ -179,13 +181,31 @@ private static NavigationRenderResult CreateIslandResult(string html, Navigation
179181
ActiveSectionId = island.ParentSection.Id
180182
};
181183

182-
private static string CombineWithSitePrefix(SiteNavigation nav, string sectionUrl)
184+
private static string CombineWithSitePrefix(SiteNavigation nav, string sectionUrl, string? currentUrl = null)
183185
{
184186
var prefix = nav.Url.TrimEnd('/');
185187
var path = sectionUrl.TrimStart('/');
186-
return string.IsNullOrEmpty(path) ? $"{prefix}/" : $"{prefix}/{path}";
188+
if (string.IsNullOrEmpty(path))
189+
return $"{prefix}/";
190+
if (IsExternalUrl(sectionUrl))
191+
return sectionUrl;
192+
if (sectionUrl.Equals(prefix, StringComparison.OrdinalIgnoreCase) || sectionUrl.StartsWith($"{prefix}/", StringComparison.OrdinalIgnoreCase))
193+
return sectionUrl;
194+
if (!string.IsNullOrEmpty(currentUrl) && sectionUrl.Length > 0 && sectionUrl[0] == '/')
195+
{
196+
var normalizedCurrentUrl = currentUrl.TrimEnd('/');
197+
var normalizedSectionPath = "/" + path.TrimEnd('/');
198+
var sectionIndex = normalizedCurrentUrl.IndexOf(normalizedSectionPath, StringComparison.OrdinalIgnoreCase);
199+
if (sectionIndex > 0)
200+
return normalizedCurrentUrl[..sectionIndex] + "/" + path;
201+
}
202+
return $"{prefix}/{path}";
187203
}
188204

205+
private static bool IsExternalUrl(string url) =>
206+
url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
207+
url.StartsWith("https://", StringComparison.OrdinalIgnoreCase);
208+
189209
private static NavigationRenderResult CreateSectionResult(string html, NavigationSection activeSection, SiteNavigationV2 navV2) =>
190210
new()
191211
{

0 commit comments

Comments
 (0)