Skip to content

Commit f01033c

Browse files
NetdocsCopilot
andcommitted
Build hierarchical auto-nav from directory structure
When no nav is authored, AutoNav dumped every page into a single flat level. On large sites (200+ pages) with navigation.tabs enabled this overflowed the header with hundreds of links instead of a usable navigation tree. Build the auto-nav from the pages' folder structure instead, mirroring MkDocs' default: each folder becomes a section (titleised from its slug), an index.md/README.md becomes the section's landing page, and entries are ordered alphabetically with index pages first. A top-level index becomes a plain home link. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 203d266 commit f01033c

2 files changed

Lines changed: 119 additions & 3 deletions

File tree

src/Netdocs.Core/Content/NavigationBuilder.cs

Lines changed: 83 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,14 +54,94 @@ private static bool IsIndexPage(string relativePath)
5454
|| name.Equals("README", StringComparison.OrdinalIgnoreCase);
5555
}
5656

57+
/// <summary>
58+
/// Builds a hierarchical nav from the pages' directory structure when no nav is
59+
/// authored — mirroring MkDocs' default behaviour. Each folder becomes a section
60+
/// (titleised from its name), an <c>index.md</c>/<c>README.md</c> becomes that
61+
/// section's landing page, and entries are ordered alphabetically with index pages
62+
/// first. A flat list (the previous behaviour) dumped every page into one level,
63+
/// which for large sites overflowed the header nav.
64+
/// </summary>
5765
private static IReadOnlyList<NavNode> AutoNav(IReadOnlyList<Page> pages)
5866
{
59-
return pages
60-
.OrderBy(p => p.RelativePath, StringComparer.OrdinalIgnoreCase)
61-
.Select(p => new NavNode { Title = p.Title, Page = p })
67+
var root = new Dir("");
68+
foreach (var page in pages)
69+
{
70+
var parts = page.RelativePath.Replace('\\', '/').Split('/', StringSplitOptions.RemoveEmptyEntries);
71+
var dir = root;
72+
for (var i = 0; i < parts.Length - 1; i++)
73+
dir = dir.Sub(parts[i]);
74+
dir.Pages.Add(page);
75+
}
76+
77+
var nodes = BuildLevel(root).ToList();
78+
79+
// A top-level index/README page becomes a plain landing link (e.g. "Home"),
80+
// not a section, so it isn't swallowed by BuildLevel's index handling.
81+
var home = root.Pages.FirstOrDefault(p => IsIndexPage(p.RelativePath));
82+
if (home is not null)
83+
nodes.Insert(0, new NavNode { Title = home.Title.Length > 0 ? home.Title : "Home", Page = home, Icon = PageIcon(home) });
84+
85+
return nodes;
86+
}
87+
88+
/// <summary>Convert a directory's sub-folders and (non-index) pages into nav nodes,
89+
/// interleaved and ordered alphabetically by name.</summary>
90+
private static List<NavNode> BuildLevel(Dir dir)
91+
{
92+
var entries = new List<(string Key, NavNode Node)>();
93+
94+
foreach (var sub in dir.Subs.Values)
95+
{
96+
var children = BuildLevel(sub);
97+
var index = sub.Pages.FirstOrDefault(p => IsIndexPage(p.RelativePath));
98+
var section = new NavNode
99+
{
100+
Title = Titleize(sub.Name),
101+
Children = children,
102+
SectionIndex = index,
103+
Icon = index is not null ? PageIcon(index) : null,
104+
};
105+
entries.Add((sub.Name, section));
106+
}
107+
108+
foreach (var page in dir.Pages.Where(p => !IsIndexPage(p.RelativePath)))
109+
{
110+
var key = Path.GetFileNameWithoutExtension(page.RelativePath);
111+
entries.Add((key, new NavNode { Title = page.Title, Page = page, Icon = PageIcon(page) }));
112+
}
113+
114+
return entries
115+
.OrderBy(e => e.Key, StringComparer.OrdinalIgnoreCase)
116+
.Select(e => e.Node)
62117
.ToList();
63118
}
64119

120+
/// <summary>Turn a folder/file slug into a human-readable section title
121+
/// (e.g. "account-management" -> "Account Management").</summary>
122+
private static string Titleize(string slug)
123+
{
124+
var words = slug.Replace('-', ' ').Replace('_', ' ').Split(' ', StringSplitOptions.RemoveEmptyEntries);
125+
for (var i = 0; i < words.Length; i++)
126+
words[i] = char.ToUpperInvariant(words[i][0]) + words[i][1..];
127+
return words.Length > 0 ? string.Join(' ', words) : slug;
128+
}
129+
130+
/// <summary>A node in the directory tree used to build the auto-nav.</summary>
131+
private sealed class Dir(string name)
132+
{
133+
public string Name { get; } = name;
134+
public SortedDictionary<string, Dir> Subs { get; } = new(StringComparer.OrdinalIgnoreCase);
135+
public List<Page> Pages { get; } = [];
136+
137+
public Dir Sub(string childName)
138+
{
139+
if (!Subs.TryGetValue(childName, out var d))
140+
Subs[childName] = d = new Dir(childName);
141+
return d;
142+
}
143+
}
144+
65145
/// <summary>Depth-first flatten of the nav tree to the linear order of real pages.</summary>
66146
public static List<Page> Flatten(IReadOnlyList<NavNode> nodes)
67147
{

tests/Netdocs.Core.Tests/ConfigTests.cs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,4 +155,40 @@ public void Flatten_IncludesSectionIndexBeforeChildren()
155155

156156
Assert.Equal(new[] { "guide/", "guide/setup/" }, flat.ConvertAll(p => p.Url));
157157
}
158+
159+
[Fact]
160+
public void AutoNav_BuildsHierarchyFromDirectories()
161+
{
162+
// No authored nav -> auto-nav should nest by folder, not dump everything flat.
163+
var pages = new List<Netdocs.Abstractions.Page>
164+
{
165+
P("index.md", ""),
166+
P("aws/index.md", "aws/"),
167+
P("aws/iam/roles.md", "aws/iam/roles/"),
168+
P("aws/iam/index.md", "aws/iam/"),
169+
P("account-management/provisioning.md", "account-management/provisioning/"),
170+
};
171+
var config = new Netdocs.Abstractions.SiteConfig();
172+
173+
var nav = Netdocs.Core.Content.NavigationBuilder.Build(config, pages);
174+
175+
// Home (root index) first, then sections ordered alphabetically by folder name.
176+
Assert.Equal("index.md", nav[0].Title); // P() uses rel path as title; root index is a link
177+
Assert.False(nav[0].IsSection);
178+
179+
var accountMgmt = Assert.Single(nav, n => n.Title == "Account Management");
180+
Assert.True(accountMgmt.IsSection);
181+
182+
var aws = Assert.Single(nav, n => n.Title == "Aws");
183+
Assert.True(aws.IsSection);
184+
Assert.NotNull(aws.SectionIndex);
185+
Assert.Equal("aws/", aws.SectionIndex!.Url);
186+
187+
// aws has a nested "Iam" section with its own index promoted to the landing.
188+
var iam = Assert.Single(aws.Children, n => n.Title == "Iam");
189+
Assert.True(iam.IsSection);
190+
Assert.Equal("aws/iam/", iam.SectionIndex!.Url);
191+
Assert.Single(iam.Children); // roles.md, index promoted out
192+
Assert.Equal("aws/iam/roles/", iam.Children[0].Url);
193+
}
158194
}

0 commit comments

Comments
 (0)