@@ -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 }
0 commit comments