-
Notifications
You must be signed in to change notification settings - Fork 38
Expand file tree
/
Copy pathDocumentationSetFile.cs
More file actions
609 lines (528 loc) · 27.1 KB
/
DocumentationSetFile.cs
File metadata and controls
609 lines (528 loc) · 27.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
// Licensed to Elasticsearch B.V under one or more agreements.
// 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.IO.Abstractions;
using Elastic.Documentation.Configuration.Products;
using Elastic.Documentation.Configuration.Toc.DetectionRules;
using Elastic.Documentation.Diagnostics;
using Elastic.Documentation.Extensions;
using YamlDotNet.Serialization;
using static Elastic.Documentation.Configuration.SymlinkValidator;
namespace Elastic.Documentation.Configuration.Toc;
[YamlSerializable]
public class DocumentationSetFile : TableOfContentsFile
{
[YamlMember(Alias = "max_toc_depth")]
public int MaxTocDepth { get; set; } = 2;
[YamlMember(Alias = "dev_docs")]
public bool DevDocs { get; set; }
[YamlMember(Alias = "cross_links")]
public List<string> CrossLinks { get; set; } = [];
[YamlMember(Alias = "exclude")]
public List<string> Exclude { get; set; } = [];
[YamlMember(Alias = "extensions")]
public List<string> Extensions { get; set; } = [];
[YamlMember(Alias = "subs")]
public Dictionary<string, string> Subs { get; set; } = [];
[Obsolete("Use the index.md h1 heading instead. This field will be removed in a future version.")]
[YamlMember(Alias = "display_name")]
public string? DisplayName { get; set; }
[Obsolete("Use the index.md frontmatter description instead. This field will be removed in a future version.")]
[YamlMember(Alias = "description")]
public string? Description { get; set; }
[YamlMember(Alias = "icon")]
public string? Icon { get; set; }
[YamlMember(Alias = "registry")]
public string? Registry { get; set; }
[YamlMember(Alias = "features")]
public DocumentationSetFeatures Features { get; set; } = new();
[YamlMember(Alias = "api")]
public Dictionary<string, string> Api { get; set; } = [];
/// <summary>
/// Default products for this documentation set. These are merged with page-level frontmatter products.
/// </summary>
[YamlMember(Alias = "products")]
public List<ProductLink> Products { get; set; } = [];
/// <summary>
/// Optional codex-specific metadata. Only contains <c>group</c> for codex navigation grouping.
/// </summary>
[YamlMember(Alias = "codex")]
public CodexDocSetMetadata? Codex { get; set; }
public static FileRef[] GetFileRefs(ITableOfContentsItem item)
{
if (item is FileRef fileRef)
return [fileRef];
if (item is FolderRef folderRef)
return folderRef.Children.SelectMany(GetFileRefs).ToArray();
if (item is IsolatedTableOfContentsRef tocRef)
return tocRef.Children.SelectMany(GetFileRefs).ToArray();
if (item is CrossLinkRef)
return [];
throw new Exception($"Unexpected item type {item.GetType().Name}");
}
private static new DocumentationSetFile Deserialize(string json) =>
ConfigurationFileProvider.Deserializer.Deserialize<DocumentationSetFile>(json);
/// <summary>
/// Loads a DocumentationSetFile from a file for reading metadata only (e.g. codex section).
/// Does not perform full TOC resolution.
/// </summary>
public static DocumentationSetFile LoadMetadata(IFileInfo file)
{
var yaml = file.FileSystem.File.ReadAllText(file.FullName);
return Deserialize(yaml);
}
/// <summary>
/// Loads a DocumentationSetFile and recursively resolves all IsolatedTableOfContentsRef items,
/// replacing them with their resolved children and ensuring file paths carry over parent paths.
/// Validates the table of contents structure and emits diagnostics for issues.
/// </summary>
public static DocumentationSetFile LoadAndResolve(IDiagnosticsCollector collector, IFileInfo docsetPath, IFileSystem? fileSystem = null, HashSet<HintType>? noSuppress = null)
{
fileSystem ??= docsetPath.FileSystem;
// Validate that the docset.yml is not a symlink (security: prevents path traversal attacks)
EnsureNotSymlink(docsetPath);
var yaml = fileSystem.File.ReadAllText(docsetPath.FullName);
var sourceDirectory = docsetPath.Directory!;
return LoadAndResolve(collector, yaml, sourceDirectory, fileSystem, noSuppress);
}
/// <summary>
/// Loads a DocumentationSetFile from YAML string and recursively resolves all IsolatedTableOfContentsRef items,
/// replacing them with their resolved children and ensuring file paths carry over parent paths.
/// Validates the table of contents structure and emits diagnostics for issues.
/// </summary>
public static DocumentationSetFile LoadAndResolve(IDiagnosticsCollector collector, string yaml, IDirectoryInfo sourceDirectory, IFileSystem? fileSystem = null, HashSet<HintType>? noSuppress = null)
{
fileSystem ??= sourceDirectory.FileSystem;
var docSet = Deserialize(yaml);
var docsetPath = fileSystem.Path.Combine(sourceDirectory.FullName, "docset.yml").OptionalWindowsReplace();
docSet.SuppressDiagnostics.ExceptWith(noSuppress ?? []);
docSet.TableOfContents = ResolveTableOfContents(collector, docSet.TableOfContents, sourceDirectory, fileSystem, parentPath: "", containerPath: "", context: docsetPath, docSet.SuppressDiagnostics);
return docSet;
}
/// <summary>
/// Recursively resolves all IsolatedTableOfContentsRef items in a table of contents,
/// loading nested TOC files and prepending parent paths to all file references.
/// Preserves the hierarchy structure without flattening.
/// Validates items and emits diagnostics for issues.
/// </summary>
private static TableOfContents ResolveTableOfContents(
IDiagnosticsCollector collector,
IReadOnlyCollection<ITableOfContentsItem> items,
IDirectoryInfo baseDirectory,
IFileSystem fileSystem,
string parentPath,
string containerPath,
string context,
HashSet<HintType>? suppressDiagnostics = null
)
{
var resolved = new TableOfContents();
foreach (var item in items)
{
var resolvedItem = item switch
{
IsolatedTableOfContentsRef tocRef => ResolveIsolatedToc(collector, tocRef, baseDirectory, fileSystem, parentPath, containerPath, context, suppressDiagnostics),
DetectionRuleOverviewRef ruleOverviewReference => ResolveRuleOverviewReference(collector, ruleOverviewReference, baseDirectory, fileSystem, parentPath, containerPath, context, suppressDiagnostics),
FileRef fileRef => ResolveFileRef(collector, fileRef, baseDirectory, fileSystem, parentPath, containerPath, context, suppressDiagnostics),
FolderRef folderRef => ResolveFolderRef(collector, folderRef, baseDirectory, fileSystem, parentPath, containerPath, context, suppressDiagnostics),
CrossLinkRef crossLink => ResolveCrossLinkRef(collector, crossLink, baseDirectory, fileSystem, parentPath, containerPath, context),
_ => null
};
if (resolvedItem != null)
resolved.Add(resolvedItem);
}
return resolved;
}
/// <summary>
/// Resolves an IsolatedTableOfContentsRef by loading the TOC file and returning a new ref with resolved children.
/// Validates that the TOC has no children in parent YAML and that toc.yml exists.
/// The TOC's path is set to the full path (including parent path) for consistency with files and folders.
/// </summary>
#pragma warning disable IDE0060 // Remove unused parameter - suppressDiagnostics is for consistency, nested TOCs use their own suppression config
private static ITableOfContentsItem? ResolveIsolatedToc(IDiagnosticsCollector collector,
IsolatedTableOfContentsRef tocRef,
IDirectoryInfo baseDirectory,
IFileSystem fileSystem,
string parentPath,
string containerPath,
string parentContext,
HashSet<HintType>? suppressDiagnostics = null
)
#pragma warning restore IDE0060
{
// TOC paths containing '/' are treated as relative to the context file's directory (full paths).
// Simple TOC names (no '/') are resolved relative to the parent path in the navigation hierarchy.
string fullTocPath;
if (tocRef.PathRelativeToDocumentationSet.Contains('/'))
{
// Path contains '/', treat as context-relative (full path from the context file's directory)
var contextDir = fileSystem.Path.GetDirectoryName(parentContext) ?? "";
var contextRelativePath = fileSystem.Path.GetRelativePath(baseDirectory.FullName, contextDir);
if (contextRelativePath == ".")
contextRelativePath = "";
fullTocPath = string.IsNullOrEmpty(contextRelativePath)
? tocRef.PathRelativeToDocumentationSet
: $"{contextRelativePath}/{tocRef.PathRelativeToDocumentationSet}";
}
else
{
// Simple name, resolve relative to parent path
fullTocPath = string.IsNullOrEmpty(parentPath) ? tocRef.PathRelativeToDocumentationSet : $"{parentPath}/{tocRef.PathRelativeToDocumentationSet}";
}
var tocDirectory = fileSystem.DirectoryInfo.New(fileSystem.Path.Combine(baseDirectory.FullName, fullTocPath));
var tocFilePath = fileSystem.Path.Combine(tocDirectory.FullName, "toc.yml");
var tocYmlExists = fileSystem.File.Exists(tocFilePath);
// Validate: TOC should not have children defined in parent YAML
if (tocRef.Children.Count > 0)
{
collector.EmitError(parentContext,
$"TableOfContents '{fullTocPath}' may not contain children, define children in '{fullTocPath}/toc.yml' instead.");
return null;
}
// PathRelativeToContainer for a TOC is the path relative to its parent container
var tocPathRelativeToContainer = string.IsNullOrEmpty(containerPath)
? fullTocPath
: fullTocPath.Substring(containerPath.Length + 1);
// If TOC has children in parent YAML, still try to load from toc.yml (prefer toc.yml over parent YAML)
if (!tocYmlExists)
{
// Validate: toc.yml file must exist
collector.EmitError(parentContext, $"Table of contents file not found: {fullTocPath}/toc.yml");
return new IsolatedTableOfContentsRef(fullTocPath, tocPathRelativeToContainer, [], parentContext);
}
// Validate that the toc.yml is not a symlink (security: prevents path traversal attacks)
EnsureNotSymlink(fileSystem, tocFilePath);
var tocYaml = fileSystem.File.ReadAllText(tocFilePath);
var nestedTocFile = TableOfContentsFile.Deserialize(tocYaml);
// this is temporary after this lands in main we can update these files to include
// suppress:
// - DeepLinkingVirtualFile
string[] skip = [
"docs-content/solutions/toc.yml",
"docs-content/manage-data/toc.yml",
"docs-content/explore-analyze/toc.yml",
"docs-content/deploy-manage/toc.yml",
"docs-content/troubleshoot/toc.yml",
"docs-content/troubleshoot/ingest/opentelemetry/toc.yml",
"docs-content/reference/security/toc.yml"
];
var path = tocFilePath.OptionalWindowsReplace();
// Hardcode suppression for known problematic files
if (skip.Any(f => path.Contains(f, StringComparison.OrdinalIgnoreCase)))
_ = nestedTocFile.SuppressDiagnostics.Add(HintType.DeepLinkingVirtualFile);
// Recursively resolve children with the FULL TOC path as the parent path
// This ensures all file paths within the TOC include the TOC directory path
// The context for children is the toc.yml file that defines them
// For children of this TOC, the container path is fullTocPath (they're defined in toc.yml at that location)
var resolvedChildren = ResolveTableOfContents(collector, nestedTocFile.TableOfContents, baseDirectory, fileSystem, fullTocPath, fullTocPath, tocFilePath, nestedTocFile.SuppressDiagnostics);
// Validate: TOC must have at least one child
if (resolvedChildren.Count == 0)
collector.EmitError(tocFilePath, $"Table of contents '{fullTocPath}' has no children defined");
// Return TOC ref with FULL path and resolved children
// The context remains the parent context (where this TOC was referenced)
return new IsolatedTableOfContentsRef(fullTocPath, tocPathRelativeToContainer, resolvedChildren, parentContext);
}
/// <summary>
/// Resolves a FileRef by prepending the parent path to the file path and recursively resolving children.
/// The parent path provides the correct context for child resolution.
/// </summary>
private static ITableOfContentsItem ResolveFileRef(IDiagnosticsCollector collector,
FileRef fileRef,
IDirectoryInfo baseDirectory,
IFileSystem fileSystem,
string parentPath,
string containerPath,
string context,
HashSet<HintType>? suppressDiagnostics = null)
{
var fullPath = string.IsNullOrEmpty(parentPath) ? fileRef.PathRelativeToDocumentationSet : $"{parentPath}/{fileRef.PathRelativeToDocumentationSet}";
// Special validation for FolderIndexFileRef (folder+file combination)
// Validate BEFORE early return so we catch cases with no children
if (fileRef is FolderIndexFileRef)
{
var fileName = fileRef.PathRelativeToDocumentationSet;
var fileWithoutExtension = fileName.Replace(".md", "");
// Validate: deep linking is NOT supported for folder+file combination
// The file path should be simple (no '/'), or at most folder/file.md after prepending
if (fileName.Contains('/'))
{
collector.EmitError(context,
$"Deep linking on folder 'file' is not supported. Found file path '{fileName}' with '/'. Use simple file name only.");
}
// Best practice: file name should match folder name (from parentPath)
// Only check if we're in a folder context (parentPath is not empty)
if (!string.IsNullOrEmpty(parentPath) && fileName != "index.md")
{
// Check if this hint type should be suppressed
if (!suppressDiagnostics.ShouldSuppress(HintType.FolderFileNameMismatch))
{
// Extract just the folder name from parentPath (in case it's nested like "guides/getting-started")
var folderName = parentPath.Contains('/') ? parentPath.Split('/')[^1] : parentPath;
// Normalize for comparison: remove hyphens, underscores, and lowercase
// This allows "getting-started" to match "GettingStarted" or "getting_started"
var normalizedFile = fileWithoutExtension.Replace("-", "", StringComparison.Ordinal).Replace("_", "", StringComparison.Ordinal).ToLowerInvariant();
var normalizedFolder = folderName.Replace("-", "", StringComparison.Ordinal).Replace("_", "", StringComparison.Ordinal).ToLowerInvariant();
if (!normalizedFile.Equals(normalizedFolder, StringComparison.Ordinal))
{
collector.EmitHint(context,
$"File name '{fileName}' does not match folder name '{folderName}'. Best practice is to name the file the same as the folder (e.g., 'folder: {folderName}, file: {folderName}.md').");
}
}
}
}
// Calculate PathRelativeToContainer: the file path relative to its container
var pathRelativeToContainer = string.IsNullOrEmpty(containerPath)
? fullPath
: fullPath.Substring(containerPath.Length + 1);
if (fileRef.Children.Count == 0)
{
// Preserve specific types even when there are no children
return fileRef switch
{
FolderIndexFileRef => new FolderIndexFileRef(fullPath, pathRelativeToContainer, fileRef.Hidden, [], context),
IndexFileRef => new IndexFileRef(fullPath, pathRelativeToContainer, fileRef.Hidden, [], context),
_ => new FileRef(fullPath, pathRelativeToContainer, fileRef.Hidden, [], context)
};
}
// Emit hint if file has children and uses deep-linking (path contains '/')
// This suggests using 'folder' instead of 'file' would be better
if (fileRef.PathRelativeToDocumentationSet.Contains('/') && fileRef.Children.Count > 0 && fileRef is not FolderIndexFileRef)
{
// Check if this hint type should be suppressed
if (!suppressDiagnostics.ShouldSuppress(HintType.DeepLinkingVirtualFile))
{
collector.EmitHint(context,
$"File '{fileRef.PathRelativeToDocumentationSet}' uses deep-linking with children. Consider using 'folder' instead of 'file' for better navigation structure. Virtual files are primarily intended to group sibling files together.");
}
}
// Children of a file should be resolved in the same directory as the parent file.
// Special handling for FolderIndexFileRef (folder+file combinations from YAML):
// - These are created when both folder and file keys exist (e.g., "folder: path/to/dir, file: index.md")
// - Children should resolve to the folder path, not the parent TOC path
// Examples:
// - Top level: "nest/guide.md" (parentPath="") → children resolve to "nest/"
// - Simple file in folder: "guide.md" (parentPath="guides") → children resolve to "guides/"
// - User file with subpath: "clients/getting-started.md" (parentPath="guides") → children resolve to "guides/"
// - Folder+file (FolderIndexFileRef): "observability/apm/apm-server/index.md" → children resolve to directory of fullPath
string parentPathForChildren;
if (fileRef is FolderIndexFileRef)
{
// Folder+file combination - extract directory from fullPath
var lastSlashIndex = fullPath.LastIndexOf('/');
parentPathForChildren = lastSlashIndex >= 0 ? fullPath[..lastSlashIndex] : "";
}
else if (string.IsNullOrEmpty(parentPath))
{
// Top level - extract directory from file path
var lastSlashIndex = fullPath.LastIndexOf('/');
parentPathForChildren = lastSlashIndex >= 0 ? fullPath[..lastSlashIndex] : "";
}
else
{
// In folder/TOC context - use parentPath directly, ignoring any subdirectory in the file reference
parentPathForChildren = parentPath;
}
// For children of files, the container is still the current context (same container as the file itself)
var resolvedChildren = ResolveTableOfContents(collector, fileRef.Children, baseDirectory, fileSystem, parentPathForChildren, containerPath, context, suppressDiagnostics);
// Preserve the specific type when creating the resolved reference
return fileRef switch
{
FolderIndexFileRef => new FolderIndexFileRef(fullPath, pathRelativeToContainer, fileRef.Hidden, resolvedChildren, context),
IndexFileRef => new IndexFileRef(fullPath, pathRelativeToContainer, fileRef.Hidden, resolvedChildren, context),
_ => new FileRef(fullPath, pathRelativeToContainer, fileRef.Hidden, resolvedChildren, context)
};
}
/// <summary>
/// Resolves a FolderRef by prepending the parent path to the folder path and recursively resolving children.
/// If no children are defined, auto-discovers .md files in the folder directory.
/// </summary>
private static ITableOfContentsItem ResolveRuleOverviewReference(IDiagnosticsCollector collector,
DetectionRuleOverviewRef detectionRuleRef,
IDirectoryInfo baseDirectory,
IFileSystem fileSystem,
string parentPath,
string containerPath,
string context,
HashSet<HintType>? suppressDiagnostics = null)
{
// Folder paths containing '/' are treated as relative to the context file's directory (full paths).
// Simple folder names (no '/') are resolved relative to the parent path in the navigation hierarchy.
string fullPath;
if (detectionRuleRef.PathRelativeToDocumentationSet.Contains('/'))
{
// Path contains '/', treat as context-relative (full path from the context file's directory)
var contextDir = fileSystem.Path.GetDirectoryName(context) ?? "";
var contextRelativePath = fileSystem.Path.GetRelativePath(baseDirectory.FullName, contextDir);
if (contextRelativePath == ".")
contextRelativePath = "";
fullPath = string.IsNullOrEmpty(contextRelativePath)
? detectionRuleRef.PathRelativeToDocumentationSet
: $"{contextRelativePath}/{detectionRuleRef.PathRelativeToDocumentationSet}";
}
else
{
// Simple name, resolve relative to parent path
fullPath = string.IsNullOrEmpty(parentPath) ? detectionRuleRef.PathRelativeToDocumentationSet : $"{parentPath}/{detectionRuleRef.PathRelativeToDocumentationSet}";
}
// Calculate PathRelativeToContainer: the folder path relative to its container
var pathRelativeToContainer = string.IsNullOrEmpty(containerPath)
? fullPath
: fullPath.Substring(containerPath.Length + 1);
// For children of folders, the container remains the same as the folder's container
var resolvedChildren = ResolveTableOfContents(collector, detectionRuleRef.Children, baseDirectory, fileSystem, fullPath, containerPath, context, suppressDiagnostics);
var fileInfo = fileSystem.NewFileInfo(baseDirectory.FullName, fullPath);
var tocSourceFolders = detectionRuleRef.DetectionRuleFolders
.Select(f => fileSystem.NewDirInfo(fileInfo.Directory!.FullName, f))
.ToList();
var tomlChildren = DetectionRuleOverviewRef.CreateTableOfContentItems(tocSourceFolders, context, baseDirectory);
var children = resolvedChildren.Concat(tomlChildren).ToList();
return new DetectionRuleOverviewRef(fullPath, pathRelativeToContainer, detectionRuleRef.DetectionRuleFolders, children, context);
}
/// <summary>
/// Resolves a FolderRef by prepending the parent path to the folder path and recursively resolving children.
/// If no children are defined, auto-discovers .md files in the folder directory.
/// </summary>
private static ITableOfContentsItem ResolveFolderRef(IDiagnosticsCollector collector,
FolderRef folderRef,
IDirectoryInfo baseDirectory,
IFileSystem fileSystem,
string parentPath,
string containerPath,
string context,
HashSet<HintType>? suppressDiagnostics = null)
{
// Folder paths containing '/' are treated as relative to the context file's directory (full paths).
// Simple folder names (no '/') are resolved relative to the parent path in the navigation hierarchy.
string fullPath;
if (folderRef.PathRelativeToDocumentationSet.Contains('/'))
{
// Path contains '/', treat as context-relative (full path from the context file's directory)
var contextDir = fileSystem.Path.GetDirectoryName(context) ?? "";
var contextRelativePath = fileSystem.Path.GetRelativePath(baseDirectory.FullName, contextDir);
if (contextRelativePath == ".")
contextRelativePath = "";
fullPath = string.IsNullOrEmpty(contextRelativePath)
? folderRef.PathRelativeToDocumentationSet
: $"{contextRelativePath}/{folderRef.PathRelativeToDocumentationSet}";
}
else
{
// Simple name, resolve relative to parent path
fullPath = string.IsNullOrEmpty(parentPath) ? folderRef.PathRelativeToDocumentationSet : $"{parentPath}/{folderRef.PathRelativeToDocumentationSet}";
}
// Calculate PathRelativeToContainer: the folder path relative to its container
var pathRelativeToContainer = string.IsNullOrEmpty(containerPath)
? fullPath
: fullPath.Substring(containerPath.Length + 1);
// Parse and validate sort order
if (!SortOrderExtensions.TryParse(folderRef.Sort, out var sortOrder) && folderRef.Sort is not null)
collector.EmitError(
context,
$"Unknown sort order '{folderRef.Sort}' for folder '{folderRef.PathRelativeToDocumentationSet}'."
+ " Valid values are: asc, ascending, desc, descending."
);
// If children are explicitly defined, resolve them
if (folderRef.Children.Count > 0)
{
// For children of folders, the container remains the same as the folder's container
var resolvedChildren = ResolveTableOfContents(collector, folderRef.Children, baseDirectory, fileSystem, fullPath, containerPath, context, suppressDiagnostics);
// Exclude is intentionally not passed through — it only applies to auto-discovery
return new FolderRef(fullPath, pathRelativeToContainer, resolvedChildren, context, folderRef.Sort);
}
// No children defined - auto-discover .md files in the folder
// null preserves the default alphabetical sorting; non-null enables natural sort for version numbers
var explicitSortOrder = folderRef.Sort is not null ? sortOrder : (SortOrder?)null;
var autoDiscoveredChildren = AutoDiscoverFolderFiles(collector, fullPath, containerPath, baseDirectory, fileSystem, context, explicitSortOrder, folderRef.Exclude);
return new FolderRef(fullPath, pathRelativeToContainer, autoDiscoveredChildren, context, folderRef.Sort, folderRef.Exclude);
}
/// <summary>
/// Auto-discovers .md files in a folder directory and creates FileRef items for them.
/// If index.md exists, it's placed first. Other files are sorted according to the specified sort order.
/// Files starting with '_' or '.' are excluded, as well as any files listed in <paramref name="exclude"/>.
/// </summary>
private static TableOfContents AutoDiscoverFolderFiles(
IDiagnosticsCollector collector,
string folderPath,
string containerPath,
IDirectoryInfo baseDirectory,
IFileSystem fileSystem,
string context,
SortOrder? sortOrder,
IReadOnlyCollection<string>? exclude)
{
var directoryPath = fileSystem.Path.Combine(baseDirectory.FullName, folderPath);
var directory = fileSystem.DirectoryInfo.New(directoryPath);
if (!directory.Exists)
return [];
// Find all .md files in the directory (not recursive)
var excludeSet = exclude is { Count: > 0 }
? new HashSet<string>(exclude, StringComparer.OrdinalIgnoreCase)
: null;
var mdFiles = fileSystem.Directory
.GetFiles(directoryPath, "*.md")
.Select(f => fileSystem.FileInfo.New(f))
.Where(f => !f.Name.StartsWith('_') && !f.Name.StartsWith('.'))
.Where(f => excludeSet is null || !excludeSet.Contains(f.Name))
.ToList();
if (mdFiles.Count == 0)
return [];
// Separate index.md from other files
var indexFile = mdFiles.FirstOrDefault(f => f.Name.Equals("index.md", StringComparison.OrdinalIgnoreCase));
var otherFiles = mdFiles.Where(f => !f.Name.Equals("index.md", StringComparison.OrdinalIgnoreCase));
// When sort is explicitly set (non-null), use natural sort order (handles version numbers correctly: 3_2 < 3_10)
// When null, preserve the original alphabetical sorting behavior
var sortedFiles = sortOrder switch
{
SortOrder.Descending => otherFiles.OrderByDescending(f => f.Name, NaturalStringComparer.Instance).ToList(),
SortOrder.Ascending => otherFiles.OrderBy(f => f.Name, NaturalStringComparer.Instance).ToList(),
_ => otherFiles.OrderBy(f => f.Name).ToList()
};
var children = new TableOfContents();
// Add index.md first if it exists
if (indexFile != null)
children.Add(new IndexFileRef(indexFile.Name, indexFile.Name, false, [], context));
// Add other files sorted according to the specified order
foreach (var file in sortedFiles)
{
var fileRef = new FileRef(file.Name, file.Name, false, [], context);
children.Add(fileRef);
}
// Resolve the children with the folder path as parent to get correct full paths
// Auto-discovered items are in the same container as the folder
return ResolveTableOfContents(collector, children, baseDirectory, fileSystem, folderPath, containerPath, context);
}
/// <summary>
/// Resolves a CrossLinkRef by recursively resolving children (though cross-links typically don't have children).
/// </summary>
private static ITableOfContentsItem ResolveCrossLinkRef(IDiagnosticsCollector collector,
CrossLinkRef crossLinkRef,
IDirectoryInfo baseDirectory,
IFileSystem fileSystem,
string parentPath,
string containerPath,
string context)
{
if (crossLinkRef.Children.Count == 0)
return new CrossLinkRef(crossLinkRef.CrossLinkUri, crossLinkRef.Title, crossLinkRef.Hidden, [], context);
// For children of cross-links, the container remains the same
var resolvedChildren = ResolveTableOfContents(collector, crossLinkRef.Children, baseDirectory, fileSystem, parentPath, containerPath, context);
return new CrossLinkRef(crossLinkRef.CrossLinkUri, crossLinkRef.Title, crossLinkRef.Hidden, resolvedChildren, context);
}
}
[YamlSerializable]
public class DocumentationSetFeatures
{
[YamlMember(Alias = "primary-nav", ApplyNamingConventions = false)]
public bool? PrimaryNav { get; set; }
[YamlMember(Alias = "disable-github-edit-link", ApplyNamingConventions = false)]
public bool? DisableGithubEditLink { get; set; }
}
/// <summary>
/// Codex-specific metadata. Only contains <c>group</c> for navigation grouping in a codex environment.
/// </summary>
[YamlSerializable]
public class CodexDocSetMetadata
{
[YamlMember(Alias = "group")]
public string? Group { get; set; }
}