Skip to content

Commit 688da4c

Browse files
NetdocsCopilot
andcommitted
Honour awesome-pages .pages files in auto-nav
When a site has no authored nav, the directory-derived auto-nav now reads awesome-pages `.pages` files alongside the content: - `title:` overrides the titleised section label (e.g. "Aws" -> "AWS", "It Processes" -> "IT Processes") - `hide: true` drops a folder from the nav entirely - `nav:` orders a folder's children, expanding `...` to "everything else" and appending any omitted entries so pages are never lost This closes the biggest gap when importing mkdocs sites that relied on the awesome-pages plugin for section titles and ordering. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 681a3e0 commit 688da4c

2 files changed

Lines changed: 192 additions & 12 deletions

File tree

src/Netdocs.Core/Content/NavigationBuilder.cs

Lines changed: 133 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -61,10 +61,17 @@ private static bool IsIndexPage(string relativePath)
6161
/// section's landing page, and entries are ordered alphabetically with index pages
6262
/// first. A flat list (the previous behaviour) dumped every page into one level,
6363
/// which for large sites overflowed the header nav.
64+
///
65+
/// Honours awesome-pages <c>.pages</c> files found alongside the content: a
66+
/// <c>title:</c> overrides the section label, <c>hide: true</c> drops the folder
67+
/// from the nav, and a <c>nav:</c> list orders the children (with <c>...</c> for
68+
/// "everything else").
6469
/// </summary>
6570
private static IReadOnlyList<NavNode> AutoNav(IReadOnlyList<Page> pages)
6671
{
67-
var root = new Dir("");
72+
var docsRoot = DocsRoot(pages);
73+
74+
var root = new Dir("", "");
6875
foreach (var page in pages)
6976
{
7077
var parts = page.RelativePath.Replace('\\', '/').Split('/', StringSplitOptions.RemoveEmptyEntries);
@@ -74,7 +81,7 @@ private static IReadOnlyList<NavNode> AutoNav(IReadOnlyList<Page> pages)
7481
dir.Pages.Add(page);
7582
}
7683

77-
var nodes = BuildLevel(root).ToList();
84+
var nodes = BuildLevel(root, docsRoot).ToList();
7885

7986
// A top-level index/README page becomes a plain landing link (e.g. "Home"),
8087
// not a section, so it isn't swallowed by BuildLevel's index handling.
@@ -85,19 +92,37 @@ private static IReadOnlyList<NavNode> AutoNav(IReadOnlyList<Page> pages)
8592
return nodes;
8693
}
8794

95+
/// <summary>The absolute docs directory, derived from a page's source and relative paths.</summary>
96+
private static string? DocsRoot(IReadOnlyList<Page> pages)
97+
{
98+
foreach (var page in pages)
99+
{
100+
if (string.IsNullOrEmpty(page.SourcePath) || string.IsNullOrEmpty(page.RelativePath))
101+
continue;
102+
var src = page.SourcePath.Replace('\\', '/');
103+
var rel = page.RelativePath.Replace('\\', '/');
104+
if (src.EndsWith(rel, StringComparison.OrdinalIgnoreCase))
105+
return src[..^rel.Length].TrimEnd('/');
106+
}
107+
return null;
108+
}
109+
88110
/// <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)
111+
/// interleaved and ordered alphabetically by name (or by the folder's <c>.pages</c> nav list).</summary>
112+
private static List<NavNode> BuildLevel(Dir dir, string? docsRoot)
91113
{
92114
var entries = new List<(string Key, NavNode Node)>();
93115

94116
foreach (var sub in dir.Subs.Values)
95117
{
96-
var children = BuildLevel(sub);
118+
var meta = PagesMeta.Load(docsRoot, sub.RelPath);
119+
if (meta.Hide) continue; // awesome-pages `hide: true`
120+
121+
var children = BuildLevel(sub, docsRoot);
97122
var index = sub.Pages.FirstOrDefault(p => IsIndexPage(p.RelativePath));
98123
var section = new NavNode
99124
{
100-
Title = Titleize(sub.Name),
125+
Title = !string.IsNullOrEmpty(meta.Title) ? meta.Title! : Titleize(sub.Name),
101126
Children = children,
102127
SectionIndex = index,
103128
Icon = index is not null ? PageIcon(index) : null,
@@ -111,10 +136,54 @@ private static List<NavNode> BuildLevel(Dir dir)
111136
entries.Add((key, new NavNode { Title = page.Title, Page = page, Icon = PageIcon(page) }));
112137
}
113138

114-
return entries
115-
.OrderBy(e => e.Key, StringComparer.OrdinalIgnoreCase)
116-
.Select(e => e.Node)
117-
.ToList();
139+
var dirMeta = PagesMeta.Load(docsRoot, dir.RelPath);
140+
return Order(entries, dirMeta.Nav);
141+
}
142+
143+
/// <summary>Order a level's entries. With no <c>.pages</c> nav list, sort
144+
/// alphabetically by name; otherwise follow the list, expanding <c>...</c> to the
145+
/// remaining entries and appending anything the list omitted (so pages are never lost).</summary>
146+
private static List<NavNode> Order(List<(string Key, NavNode Node)> entries, IReadOnlyList<string>? navList)
147+
{
148+
if (navList is null || navList.Count == 0)
149+
{
150+
return entries
151+
.OrderBy(e => e.Key, StringComparer.OrdinalIgnoreCase)
152+
.Select(e => e.Node)
153+
.ToList();
154+
}
155+
156+
var remaining = entries.ToList();
157+
var result = new List<NavNode>();
158+
var restAt = -1;
159+
160+
foreach (var raw in navList)
161+
{
162+
var name = raw.Trim();
163+
if (name == "...")
164+
{
165+
restAt = result.Count;
166+
continue;
167+
}
168+
169+
var key = name.EndsWith(".md", StringComparison.OrdinalIgnoreCase) ? name[..^3] : name;
170+
if (IsIndexName(key)) continue; // the index is the section landing, not a child
171+
172+
var i = remaining.FindIndex(e => string.Equals(e.Key, key, StringComparison.OrdinalIgnoreCase));
173+
if (i >= 0)
174+
{
175+
result.Add(remaining[i].Node);
176+
remaining.RemoveAt(i);
177+
}
178+
}
179+
180+
// Whatever the list didn't mention goes at the `...` marker (or the end),
181+
// in default alphabetical order.
182+
var rest = remaining.OrderBy(e => e.Key, StringComparer.OrdinalIgnoreCase).Select(e => e.Node);
183+
if (restAt >= 0) result.InsertRange(restAt, rest);
184+
else result.AddRange(rest);
185+
186+
return result;
118187
}
119188

120189
/// <summary>Turn a folder/file slug into a human-readable section title
@@ -127,17 +196,69 @@ private static string Titleize(string slug)
127196
return words.Length > 0 ? string.Join(' ', words) : slug;
128197
}
129198

199+
private static bool IsIndexName(string name) =>
200+
name.Equals("index", StringComparison.OrdinalIgnoreCase)
201+
|| name.Equals("README", StringComparison.OrdinalIgnoreCase);
202+
203+
/// <summary>Minimal reader for awesome-pages <c>.pages</c> files (title / hide / nav list).</summary>
204+
private sealed class PagesMeta
205+
{
206+
public string? Title { get; private init; }
207+
public bool Hide { get; private init; }
208+
public IReadOnlyList<string>? Nav { get; private init; }
209+
210+
private static readonly PagesMeta Empty = new();
211+
212+
public static PagesMeta Load(string? docsRoot, string relDir)
213+
{
214+
if (string.IsNullOrEmpty(docsRoot)) return Empty;
215+
var path = Path.Combine(docsRoot, relDir.Replace('/', Path.DirectorySeparatorChar), ".pages");
216+
if (!File.Exists(path)) return Empty;
217+
218+
string? title = null;
219+
var hide = false;
220+
List<string>? nav = null;
221+
var inNav = false;
222+
223+
foreach (var line in File.ReadLines(path))
224+
{
225+
var trimmed = line.Trim();
226+
if (trimmed.Length == 0 || trimmed.StartsWith('#')) continue;
227+
228+
if (inNav)
229+
{
230+
if (trimmed.StartsWith("- "))
231+
{
232+
(nav ??= []).Add(trimmed[2..].Trim().Trim('"', '\''));
233+
continue;
234+
}
235+
inNav = false; // a non-list line ends the nav block
236+
}
237+
238+
if (trimmed.StartsWith("title:", StringComparison.OrdinalIgnoreCase))
239+
title = trimmed[6..].Trim().Trim('"', '\'');
240+
else if (trimmed.StartsWith("hide:", StringComparison.OrdinalIgnoreCase))
241+
hide = trimmed[5..].Trim().Equals("true", StringComparison.OrdinalIgnoreCase);
242+
else if (trimmed.StartsWith("nav:", StringComparison.OrdinalIgnoreCase))
243+
inNav = true;
244+
}
245+
246+
return new PagesMeta { Title = title, Hide = hide, Nav = nav };
247+
}
248+
}
249+
130250
/// <summary>A node in the directory tree used to build the auto-nav.</summary>
131-
private sealed class Dir(string name)
251+
private sealed class Dir(string name, string relPath)
132252
{
133253
public string Name { get; } = name;
254+
public string RelPath { get; } = relPath;
134255
public SortedDictionary<string, Dir> Subs { get; } = new(StringComparer.OrdinalIgnoreCase);
135256
public List<Page> Pages { get; } = [];
136257

137258
public Dir Sub(string childName)
138259
{
139260
if (!Subs.TryGetValue(childName, out var d))
140-
Subs[childName] = d = new Dir(childName);
261+
Subs[childName] = d = new Dir(childName, RelPath.Length == 0 ? childName : RelPath + "/" + childName);
141262
return d;
142263
}
143264
}

tests/Netdocs.Core.Tests/ConfigTests.cs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,4 +191,63 @@ public void AutoNav_BuildsHierarchyFromDirectories()
191191
Assert.Single(iam.Children); // roles.md, index promoted out
192192
Assert.Equal("aws/iam/roles/", iam.Children[0].Url);
193193
}
194+
195+
[Fact]
196+
public void AutoNav_HonoursAwesomePagesMetadata()
197+
{
198+
// Build a real docs tree on disk with awesome-pages `.pages` files: a title
199+
// override, a hidden folder, and an explicit child ordering with `...`.
200+
var docsRoot = Path.Combine(Path.GetTempPath(), "ndpages-" + Guid.NewGuid().ToString("N"));
201+
try
202+
{
203+
void Write(string rel, string body)
204+
{
205+
var full = Path.Combine(docsRoot, rel.Replace('/', Path.DirectorySeparatorChar));
206+
Directory.CreateDirectory(Path.GetDirectoryName(full)!);
207+
File.WriteAllText(full, body);
208+
}
209+
210+
Write("aws/.pages", "title: AWS\nnav:\n - index.md\n - zebra.md\n - ...\n");
211+
Write("aws/index.md", "");
212+
Write("aws/zebra.md", "");
213+
Write("aws/alpha.md", "");
214+
Write("aws/mango.md", "");
215+
Write("projects/.pages", "hide: true\n");
216+
Write("projects/secret.md", "");
217+
218+
Netdocs.Abstractions.Page DP(string rel) =>
219+
new()
220+
{
221+
SourcePath = Path.Combine(docsRoot, rel.Replace('/', Path.DirectorySeparatorChar)),
222+
RelativePath = rel,
223+
Url = rel.Replace(".md", "/"),
224+
Title = Path.GetFileNameWithoutExtension(rel),
225+
};
226+
227+
var pages = new List<Netdocs.Abstractions.Page>
228+
{
229+
DP("aws/index.md"),
230+
DP("aws/zebra.md"),
231+
DP("aws/alpha.md"),
232+
DP("aws/mango.md"),
233+
DP("projects/secret.md"),
234+
};
235+
236+
var nav = Netdocs.Core.Content.NavigationBuilder.Build(new Netdocs.Abstractions.SiteConfig(), pages);
237+
238+
// `hide: true` drops the projects section entirely.
239+
Assert.DoesNotContain(nav, n => n.Title == "Projects");
240+
241+
// `title: AWS` overrides the titleised "Aws".
242+
var aws = Assert.Single(nav, n => n.Title == "AWS");
243+
244+
// Ordering: zebra first (explicit), then the rest alphabetically (alpha, mango).
245+
// index.md is the section landing, not a child.
246+
Assert.Equal(new[] { "zebra", "alpha", "mango" }, aws.Children.Select(c => c.Title).ToArray());
247+
}
248+
finally
249+
{
250+
Directory.Delete(docsRoot, recursive: true);
251+
}
252+
}
194253
}

0 commit comments

Comments
 (0)