From 290de05f7169337209dabc374ca585326c0dcc02 Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Tue, 7 Jul 2026 21:17:12 +0200 Subject: [PATCH 1/2] feat: implement core navigation and frontmatter parsing functionality. Add NavigationGraph, NavigationNode, and related builders to manage document structure. Introduce FrontmatterParser for YAML frontmatter extraction and MetaJson for metadata handling. --- src/ShellDocs.Core/FrontmatterParser.cs | 69 ++++++++ src/ShellDocs.Core/Heading.cs | 3 + src/ShellDocs.Core/MetaJson.cs | 80 +++++++++ src/ShellDocs.Core/NavigationGraph.cs | 77 +++++++++ src/ShellDocs.Core/NavigationGraphBuilder.cs | 165 +++++++++++++++++++ src/ShellDocs.Core/NavigationNode.cs | 16 +- 6 files changed, 406 insertions(+), 4 deletions(-) create mode 100644 src/ShellDocs.Core/FrontmatterParser.cs create mode 100644 src/ShellDocs.Core/Heading.cs create mode 100644 src/ShellDocs.Core/MetaJson.cs create mode 100644 src/ShellDocs.Core/NavigationGraph.cs create mode 100644 src/ShellDocs.Core/NavigationGraphBuilder.cs diff --git a/src/ShellDocs.Core/FrontmatterParser.cs b/src/ShellDocs.Core/FrontmatterParser.cs new file mode 100644 index 0000000..c2b5f09 --- /dev/null +++ b/src/ShellDocs.Core/FrontmatterParser.cs @@ -0,0 +1,69 @@ +using YamlDotNet.Serialization; + +namespace ShellDocs.Core; + +public static class FrontmatterParser +{ + private static readonly string[] LineSeparators = new[] { "\r\n", "\n" }; + private static readonly IDeserializer YamlDeserializer = new DeserializerBuilder().Build(); + + public static ParsedDocument Parse(string source) + { + if (string.IsNullOrEmpty(source)) + { + return new ParsedDocument(new Dictionary(), source ?? ""); + } + + var lines = source.Split(LineSeparators, StringSplitOptions.None); + if (lines.Length < 2 || lines[0].TrimEnd() != "---") + { + return new ParsedDocument(new Dictionary(), source); + } + + var closingIndex = -1; + for (var i = 1; i < lines.Length; i++) + { + if (lines[i].TrimEnd() == "---") + { + closingIndex = i; + break; + } + } + if (closingIndex == -1) + { + return new ParsedDocument(new Dictionary(), source); + } + + var yaml = string.Join("\n", lines[1..closingIndex]); + var body = string.Join("\n", lines[(closingIndex + 1)..]); + + Dictionary frontmatter; + try + { + frontmatter = YamlDeserializer.Deserialize>(yaml) + ?? new Dictionary(); + } + catch (YamlDotNet.Core.YamlException) + { + frontmatter = new Dictionary(); + } + + return new ParsedDocument(frontmatter, body); + } + + public static T? GetValue(this IReadOnlyDictionary frontmatter, string key) + { + if (!frontmatter.TryGetValue(key, out var raw) || raw is null) return default; + if (raw is T typed) return typed; + try + { + return (T)Convert.ChangeType(raw, typeof(T)); + } + catch + { + return default; + } + } +} + +public record ParsedDocument(IReadOnlyDictionary Frontmatter, string Body); diff --git a/src/ShellDocs.Core/Heading.cs b/src/ShellDocs.Core/Heading.cs new file mode 100644 index 0000000..fccfe3e --- /dev/null +++ b/src/ShellDocs.Core/Heading.cs @@ -0,0 +1,3 @@ +namespace ShellDocs.Core; + +public record Heading(int Level, string Text, string Id); diff --git a/src/ShellDocs.Core/MetaJson.cs b/src/ShellDocs.Core/MetaJson.cs new file mode 100644 index 0000000..a7e653e --- /dev/null +++ b/src/ShellDocs.Core/MetaJson.cs @@ -0,0 +1,80 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ShellDocs.Core; + +public class MetaJson +{ + [JsonPropertyName("title")] + public string? Title { get; set; } + + [JsonPropertyName("pages")] + public List Pages { get; set; } = new(); + + private static readonly JsonSerializerOptions Options = new() + { + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + Converters = { new MetaJsonEntryConverter() } + }; + + public static MetaJson? Parse(string json) + { + if (string.IsNullOrWhiteSpace(json)) return null; + return JsonSerializer.Deserialize(json, Options); + } +} + +public abstract class MetaJsonEntry { } +public class MetaJsonPageRef : MetaJsonEntry { public required string Slug { get; init; } } +public class MetaJsonDivider : MetaJsonEntry { } +public class MetaJsonSubsection : MetaJsonEntry +{ + public required string Title { get; init; } + public List Pages { get; init; } = new(); +} + +/* + Each entry in meta.json's "pages" array can be: + - a string slug ("button") + - the literal "---" for a section divider + - an object { "title": "Data Display", "pages": [...] } for a subsection + Custom converter dispatches on the JSON token type. +*/ +internal class MetaJsonEntryConverter : JsonConverter +{ + public override MetaJsonEntry? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.String) + { + var value = reader.GetString() ?? ""; + return value == "---" ? new MetaJsonDivider() : new MetaJsonPageRef { Slug = value }; + } + + if (reader.TokenType == JsonTokenType.StartObject) + { + using var doc = JsonDocument.ParseValue(ref reader); + var root = doc.RootElement; + var title = root.TryGetProperty("title", out var t) ? t.GetString() ?? "" : ""; + var pages = new List(); + if (root.TryGetProperty("pages", out var p) && p.ValueKind == JsonValueKind.Array) + { + foreach (var el in p.EnumerateArray()) + { + var elJson = el.GetRawText(); + var childReader = new Utf8JsonReader(System.Text.Encoding.UTF8.GetBytes(elJson)); + childReader.Read(); + var child = Read(ref childReader, typeof(MetaJsonEntry), options); + if (child is not null) pages.Add(child); + } + } + return new MetaJsonSubsection { Title = title, Pages = pages }; + } + + throw new JsonException($"Unexpected token {reader.TokenType} in meta.json entry."); + } + + public override void Write(Utf8JsonWriter writer, MetaJsonEntry value, JsonSerializerOptions options) + => throw new NotSupportedException("Writing meta.json entries not supported."); +} diff --git a/src/ShellDocs.Core/NavigationGraph.cs b/src/ShellDocs.Core/NavigationGraph.cs new file mode 100644 index 0000000..03176d4 --- /dev/null +++ b/src/ShellDocs.Core/NavigationGraph.cs @@ -0,0 +1,77 @@ +namespace ShellDocs.Core; + +public class NavigationGraph +{ + public NavigationNode Root { get; } + + private readonly Dictionary _byUrl; + private readonly List _flatPages; + + public NavigationGraph(NavigationNode root) + { + Root = root; + _byUrl = new Dictionary(StringComparer.OrdinalIgnoreCase); + _flatPages = new List(); + Index(root); + } + + public NavigationNode? ResolveByUrl(string url) + { + var key = Normalize(url); + return _byUrl.TryGetValue(key, out var node) ? node : null; + } + + public (NavigationNode? Prev, NavigationNode? Next) GetPrevNext(NavigationNode node) + { + var i = _flatPages.IndexOf(node); + if (i < 0) return (null, null); + var prev = i > 0 ? _flatPages[i - 1] : null; + var next = i < _flatPages.Count - 1 ? _flatPages[i + 1] : null; + return (prev, next); + } + + public IReadOnlyList GetBreadcrumb(NavigationNode node) + { + var chain = new List(); + var current = node; + while (current is not null && current != Root) + { + chain.Add(current); + current = current.Parent; + } + chain.Reverse(); + return chain; + } + + public IEnumerable Flatten() + { + return FlattenFrom(Root); + } + + private static IEnumerable FlattenFrom(NavigationNode node) + { + yield return node; + foreach (var child in node.Children) + { + foreach (var n in FlattenFrom(child)) yield return n; + } + } + + private void Index(NavigationNode node) + { + if (node.Kind == NodeKind.Page && !string.IsNullOrEmpty(node.Url)) + { + _byUrl[Normalize(node.Url)] = node; + _flatPages.Add(node); + } + foreach (var child in node.Children) Index(child); + } + + private static string Normalize(string url) + { + var s = url.Trim(); + if (!s.StartsWith('/')) s = "/" + s; + if (s.Length > 1 && s.EndsWith('/')) s = s[..^1]; + return s; + } +} diff --git a/src/ShellDocs.Core/NavigationGraphBuilder.cs b/src/ShellDocs.Core/NavigationGraphBuilder.cs new file mode 100644 index 0000000..34458d8 --- /dev/null +++ b/src/ShellDocs.Core/NavigationGraphBuilder.cs @@ -0,0 +1,165 @@ +namespace ShellDocs.Core; + +public static class NavigationGraphBuilder +{ + public static NavigationGraph Build(string contentRoot) + { + var abs = System.IO.Path.GetFullPath(contentRoot); + if (!Directory.Exists(abs)) + { + throw new DirectoryNotFoundException($"Content root not found: {contentRoot}"); + } + + var rootNode = new NavigationNode + { + Title = "", + Url = "/", + Kind = NodeKind.Section, + Path = abs + }; + + var children = BuildFolder(abs, abs, urlPrefix: ""); + LinkChildren(rootNode, children); + return new NavigationGraph(rootNode); + } + + private static List BuildFolder(string folder, string root, string urlPrefix) + { + var meta = ReadMeta(folder); + var mdFiles = Directory.GetFiles(folder, "*.md", SearchOption.TopDirectoryOnly); + var subfolders = Directory.GetDirectories(folder); + + var slugToNode = mdFiles.ToDictionary( + path => SlugFrom(path), + path => BuildPage(path, root, urlPrefix), + StringComparer.OrdinalIgnoreCase); + + var folderNameToChildren = subfolders.ToDictionary( + path => System.IO.Path.GetFileName(path), + path => (folderPath: path, children: BuildFolder(path, root, CombineUrl(urlPrefix, System.IO.Path.GetFileName(path)))), + StringComparer.OrdinalIgnoreCase); + + // No meta.json: alphabetical ordering, subfolders inline as sections. + if (meta is null) + { + var ordered = new List(); + foreach (var (name, tuple) in folderNameToChildren.OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase)) + { + var section = new NavigationNode + { + Title = TitleFromFolderName(name), + Kind = NodeKind.Section, + Path = tuple.folderPath + }; + LinkChildren(section, tuple.children); + ordered.Add(section); + } + ordered.AddRange(slugToNode.Values.OrderBy(n => n.Order).ThenBy(n => n.Title, StringComparer.OrdinalIgnoreCase)); + return ordered; + } + + var result = new List(); + foreach (var entry in meta.Pages) + { + var node = ResolveEntry(entry, slugToNode, folderNameToChildren, root, urlPrefix); + if (node is not null) result.Add(node); + } + return result; + } + + private static NavigationNode? ResolveEntry( + MetaJsonEntry entry, + Dictionary slugToNode, + Dictionary children)> folderNameToChildren, + string root, + string urlPrefix) + { + switch (entry) + { + case MetaJsonPageRef pageRef: + if (slugToNode.TryGetValue(pageRef.Slug, out var page)) return page; + if (folderNameToChildren.TryGetValue(pageRef.Slug, out var folder)) + { + var section = new NavigationNode + { + Title = TitleFromFolderName(pageRef.Slug), + Kind = NodeKind.Section, + Path = folder.folderPath + }; + LinkChildren(section, folder.children); + return section; + } + return null; + + case MetaJsonDivider: + return new NavigationNode { Kind = NodeKind.Divider }; + + case MetaJsonSubsection sub: + var subNode = new NavigationNode + { + Title = sub.Title, + Kind = NodeKind.Section + }; + var subChildren = new List(); + foreach (var e in sub.Pages) + { + var child = ResolveEntry(e, slugToNode, folderNameToChildren, root, urlPrefix); + if (child is not null) subChildren.Add(child); + } + LinkChildren(subNode, subChildren); + return subNode; + + default: + return null; + } + } + + private static NavigationNode BuildPage(string path, string root, string urlPrefix) + { + var raw = File.ReadAllText(path); + var parsed = FrontmatterParser.Parse(raw); + var slug = SlugFrom(path); + var url = slug == "index" ? EnsureLeadingSlash(urlPrefix) : CombineUrl(urlPrefix, slug); + if (url == "") url = "/"; + + return new NavigationNode + { + Url = url, + Title = parsed.Frontmatter.GetValue("title") ?? TitleFromSlug(slug), + Description = parsed.Frontmatter.GetValue("description"), + Category = parsed.Frontmatter.GetValue("category"), + Order = parsed.Frontmatter.GetValue("order"), + Path = path, + Kind = NodeKind.Page + }; + } + + private static MetaJson? ReadMeta(string folder) + { + var metaPath = System.IO.Path.Combine(folder, "meta.json"); + return File.Exists(metaPath) ? MetaJson.Parse(File.ReadAllText(metaPath)) : null; + } + + private static void LinkChildren(NavigationNode parent, IReadOnlyList children) + { + foreach (var child in children) child.Parent = parent; + parent.Children = children; + } + + private static string SlugFrom(string mdPath) + => System.IO.Path.GetFileNameWithoutExtension(mdPath); + + private static string TitleFromSlug(string slug) + => string.Join(' ', slug.Split('-').Select(w => w.Length == 0 ? w : char.ToUpper(w[0]) + w[1..])); + + private static string TitleFromFolderName(string name) => TitleFromSlug(name); + + private static string EnsureLeadingSlash(string s) => s.StartsWith('/') ? s : "/" + s; + + private static string CombineUrl(string prefix, string segment) + { + var p = prefix.TrimEnd('/'); + var s = segment.Trim('/'); + return string.IsNullOrEmpty(p) ? "/" + s : p + "/" + s; + } +} diff --git a/src/ShellDocs.Core/NavigationNode.cs b/src/ShellDocs.Core/NavigationNode.cs index d3cad6b..454ef33 100644 --- a/src/ShellDocs.Core/NavigationNode.cs +++ b/src/ShellDocs.Core/NavigationNode.cs @@ -1,17 +1,25 @@ namespace ShellDocs.Core; -/// One page in the ShellDocs navigation graph. Built from a .md file's frontmatter + path. public class NavigationNode { - public required string Url { get; init; } - public required string Title { get; init; } + public string Url { get; init; } = ""; + public string Title { get; init; } = ""; public string? Description { get; init; } public string? Category { get; init; } public int Order { get; init; } public string? Path { get; init; } + public NodeKind Kind { get; init; } = NodeKind.Page; + + // Populated at render time by ShellDocs.Markdown; empty until then. public IReadOnlyList Headings { get; init; } = Array.Empty(); + public NavigationNode? Parent { get; internal set; } public IReadOnlyList Children { get; internal set; } = Array.Empty(); } -public record Heading(int Level, string Text, string Id); +public enum NodeKind +{ + Page, + Section, + Divider +} From 0546870dfd891d21118d1e883bec77440a7ce26b Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Tue, 7 Jul 2026 21:17:22 +0200 Subject: [PATCH 2/2] test: add comprehensive unit tests for FrontmatterParser, MetaJson, and NavigationGraphBuilder. Validate parsing, handling of edge cases, and document structure resolution. --- .../ShellDocs.Tests/FrontmatterParserTests.cs | 70 ++++++ tests/ShellDocs.Tests/MetaJsonTests.cs | 71 ++++++ .../NavigationGraphBuilderTests.cs | 203 ++++++++++++++++++ tests/ShellDocs.Tests/ScaffoldingTests.cs | 11 - 4 files changed, 344 insertions(+), 11 deletions(-) create mode 100644 tests/ShellDocs.Tests/FrontmatterParserTests.cs create mode 100644 tests/ShellDocs.Tests/MetaJsonTests.cs create mode 100644 tests/ShellDocs.Tests/NavigationGraphBuilderTests.cs diff --git a/tests/ShellDocs.Tests/FrontmatterParserTests.cs b/tests/ShellDocs.Tests/FrontmatterParserTests.cs new file mode 100644 index 0000000..c91c7f3 --- /dev/null +++ b/tests/ShellDocs.Tests/FrontmatterParserTests.cs @@ -0,0 +1,70 @@ +using ShellDocs.Core; +using Xunit; + +namespace ShellDocs.Tests; + +public class FrontmatterParserTests +{ + [Fact] + public void Parse_ExtractsFrontmatterAndBody() + { + var source = "---\ntitle: Button\ndescription: A clickable thing\norder: 5\n---\n# Button\n\nBody here."; + + var result = FrontmatterParser.Parse(source); + + Assert.Equal("Button", result.Frontmatter.GetValue("title")); + Assert.Equal("A clickable thing", result.Frontmatter.GetValue("description")); + Assert.Equal(5, result.Frontmatter.GetValue("order")); + Assert.Contains("# Button", result.Body); + Assert.Contains("Body here.", result.Body); + } + + [Fact] + public void Parse_HandlesMissingFrontmatter() + { + var source = "# No Frontmatter\n\nJust markdown."; + var result = FrontmatterParser.Parse(source); + + Assert.Empty(result.Frontmatter); + Assert.Equal(source, result.Body); + } + + [Fact] + public void Parse_HandlesEmptyDocument() + { + var result = FrontmatterParser.Parse(""); + + Assert.Empty(result.Frontmatter); + Assert.Equal("", result.Body); + } + + [Fact] + public void Parse_HandlesUnclosedFrontmatter() + { + var source = "---\ntitle: Broken\n\n# Body"; + var result = FrontmatterParser.Parse(source); + + Assert.Empty(result.Frontmatter); + Assert.Equal(source, result.Body); + } + + [Fact] + public void Parse_HandlesInvalidYaml() + { + var source = "---\ntitle: [unclosed\n---\n# Body"; + var result = FrontmatterParser.Parse(source); + + Assert.Empty(result.Frontmatter); + Assert.Contains("# Body", result.Body); + } + + [Fact] + public void GetValue_CoercesTypes() + { + var source = "---\nage: 42\n---\n"; + var result = FrontmatterParser.Parse(source); + + Assert.Equal(42, result.Frontmatter.GetValue("age")); + Assert.Equal("42", result.Frontmatter.GetValue("age")); + } +} diff --git a/tests/ShellDocs.Tests/MetaJsonTests.cs b/tests/ShellDocs.Tests/MetaJsonTests.cs new file mode 100644 index 0000000..d8a7e75 --- /dev/null +++ b/tests/ShellDocs.Tests/MetaJsonTests.cs @@ -0,0 +1,71 @@ +using ShellDocs.Core; +using Xunit; + +namespace ShellDocs.Tests; + +public class MetaJsonTests +{ + [Fact] + public void Parse_HandlesFlatSlugList() + { + var json = """ + { "title": "Docs", "pages": ["intro", "installation", "theming"] } + """; + + var meta = MetaJson.Parse(json); + + Assert.NotNull(meta); + Assert.Equal("Docs", meta!.Title); + Assert.Equal(3, meta.Pages.Count); + Assert.All(meta.Pages, e => Assert.IsType(e)); + Assert.Equal("intro", ((MetaJsonPageRef)meta.Pages[0]).Slug); + } + + [Fact] + public void Parse_RecognizesDivider() + { + var json = """ + { "pages": ["a", "---", "b"] } + """; + + var meta = MetaJson.Parse(json); + + Assert.NotNull(meta); + Assert.IsType(meta!.Pages[0]); + Assert.IsType(meta.Pages[1]); + Assert.IsType(meta.Pages[2]); + } + + [Fact] + public void Parse_HandlesSubsectionObject() + { + var json = """ + { + "title": "Components", + "pages": [ + "button", + { + "title": "Data Display", + "pages": ["table", "card"] + } + ] + } + """; + + var meta = MetaJson.Parse(json); + + Assert.NotNull(meta); + Assert.Equal(2, meta!.Pages.Count); + var sub = Assert.IsType(meta.Pages[1]); + Assert.Equal("Data Display", sub.Title); + Assert.Equal(2, sub.Pages.Count); + Assert.All(sub.Pages, e => Assert.IsType(e)); + } + + [Fact] + public void Parse_ReturnsNullForBlank() + { + Assert.Null(MetaJson.Parse("")); + Assert.Null(MetaJson.Parse(" ")); + } +} diff --git a/tests/ShellDocs.Tests/NavigationGraphBuilderTests.cs b/tests/ShellDocs.Tests/NavigationGraphBuilderTests.cs new file mode 100644 index 0000000..97ea95b --- /dev/null +++ b/tests/ShellDocs.Tests/NavigationGraphBuilderTests.cs @@ -0,0 +1,203 @@ +using ShellDocs.Core; +using Xunit; + +namespace ShellDocs.Tests; + +public class NavigationGraphBuilderTests : IDisposable +{ + private readonly string _root; + + public NavigationGraphBuilderTests() + { + _root = Path.Combine(Path.GetTempPath(), "shelldocs-test-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_root); + } + + public void Dispose() + { + try { Directory.Delete(_root, recursive: true); } catch { } + } + + private void WriteMd(string relativePath, string title, int order = 0) + { + var full = Path.Combine(_root, relativePath); + Directory.CreateDirectory(Path.GetDirectoryName(full)!); + File.WriteAllText(full, $"---\ntitle: {title}\norder: {order}\n---\n# {title}\n"); + } + + private void WriteMeta(string folder, string json) + { + var full = Path.Combine(_root, folder, "meta.json"); + Directory.CreateDirectory(Path.GetDirectoryName(full)!); + File.WriteAllText(full, json); + } + + [Fact] + public void Build_SingleFile_ResolvesByUrl() + { + WriteMd("intro.md", "Introduction"); + + var graph = NavigationGraphBuilder.Build(_root); + var node = graph.ResolveByUrl("/intro"); + + Assert.NotNull(node); + Assert.Equal("Introduction", node!.Title); + Assert.Equal(NodeKind.Page, node.Kind); + } + + [Fact] + public void Build_UsesFrontmatterTitle_FallsBackToSlug() + { + WriteMd("with-title.md", "Explicit"); + var noFrontmatter = Path.Combine(_root, "no-title.md"); + File.WriteAllText(noFrontmatter, "# Just markdown"); + + var graph = NavigationGraphBuilder.Build(_root); + + Assert.Equal("Explicit", graph.ResolveByUrl("/with-title")!.Title); + Assert.Equal("No Title", graph.ResolveByUrl("/no-title")!.Title); + } + + [Fact] + public void Build_NestedFolders_AreSections() + { + WriteMd("docs/introduction.md", "Introduction"); + WriteMd("docs/installation.md", "Installation"); + WriteMd("components/button.md", "Button"); + + var graph = NavigationGraphBuilder.Build(_root); + + Assert.NotNull(graph.ResolveByUrl("/docs/introduction")); + Assert.NotNull(graph.ResolveByUrl("/docs/installation")); + Assert.NotNull(graph.ResolveByUrl("/components/button")); + + var sections = graph.Root.Children.Where(c => c.Kind == NodeKind.Section).ToList(); + Assert.Contains(sections, s => s.Title == "Components"); + Assert.Contains(sections, s => s.Title == "Docs"); + } + + [Fact] + public void Build_UsesMetaJsonOrdering() + { + WriteMd("alpha.md", "Alpha"); + WriteMd("bravo.md", "Bravo"); + WriteMd("charlie.md", "Charlie"); + WriteMeta("", """{ "pages": ["charlie", "alpha", "bravo"] }"""); + + var graph = NavigationGraphBuilder.Build(_root); + var titles = graph.Root.Children.Select(c => c.Title).ToList(); + + Assert.Equal(new[] { "Charlie", "Alpha", "Bravo" }, titles); + } + + [Fact] + public void Build_MetaJsonDivider_ProducesDividerNode() + { + WriteMd("a.md", "A"); + WriteMd("b.md", "B"); + WriteMeta("", """{ "pages": ["a", "---", "b"] }"""); + + var graph = NavigationGraphBuilder.Build(_root); + + var kinds = graph.Root.Children.Select(c => c.Kind).ToList(); + Assert.Equal(new[] { NodeKind.Page, NodeKind.Divider, NodeKind.Page }, kinds); + } + + [Fact] + public void Build_MetaJsonSubsection_CreatesNamedSection() + { + WriteMd("table.md", "Table"); + WriteMd("card.md", "Card"); + WriteMeta("", """ + { + "pages": [ + { + "title": "Data Display", + "pages": ["table", "card"] + } + ] + } + """); + + var graph = NavigationGraphBuilder.Build(_root); + var section = Assert.Single(graph.Root.Children); + + Assert.Equal("Data Display", section.Title); + Assert.Equal(NodeKind.Section, section.Kind); + Assert.Equal(new[] { "Table", "Card" }, section.Children.Select(c => c.Title)); + } + + [Fact] + public void Build_UnknownSlugInMetaJson_IsSilentlySkipped() + { + WriteMd("real.md", "Real"); + WriteMeta("", """{ "pages": ["real", "does-not-exist"] }"""); + + var graph = NavigationGraphBuilder.Build(_root); + var titles = graph.Root.Children.Select(c => c.Title).ToList(); + + Assert.Equal(new[] { "Real" }, titles); + } + + [Fact] + public void Build_ThrowsOnMissingContentRoot() + { + Assert.Throws( + () => NavigationGraphBuilder.Build(Path.Combine(_root, "does-not-exist"))); + } + + [Fact] + public void GetPrevNext_ReturnsAdjacentPages() + { + WriteMd("first.md", "First", order: 1); + WriteMd("second.md", "Second", order: 2); + WriteMd("third.md", "Third", order: 3); + + var graph = NavigationGraphBuilder.Build(_root); + var second = graph.ResolveByUrl("/second")!; + + var (prev, next) = graph.GetPrevNext(second); + + Assert.Equal("First", prev?.Title); + Assert.Equal("Third", next?.Title); + } + + [Fact] + public void GetPrevNext_HandlesBoundaries() + { + WriteMd("only.md", "Only"); + + var graph = NavigationGraphBuilder.Build(_root); + var only = graph.ResolveByUrl("/only")!; + + var (prev, next) = graph.GetPrevNext(only); + + Assert.Null(prev); + Assert.Null(next); + } + + [Fact] + public void GetBreadcrumb_WalksUpFromLeafToTopLevel() + { + WriteMd("docs/theming/tokens.md", "Tokens"); + + var graph = NavigationGraphBuilder.Build(_root); + var leaf = graph.ResolveByUrl("/docs/theming/tokens")!; + var trail = graph.GetBreadcrumb(leaf); + + Assert.Equal(new[] { "Docs", "Theming", "Tokens" }, trail.Select(n => n.Title)); + } + + [Fact] + public void ResolveByUrl_IsCaseInsensitiveAndTrailingSlashTolerant() + { + WriteMd("Button.md", "Button"); + + var graph = NavigationGraphBuilder.Build(_root); + + Assert.NotNull(graph.ResolveByUrl("/button")); + Assert.NotNull(graph.ResolveByUrl("/BUTTON")); + Assert.NotNull(graph.ResolveByUrl("/button/")); + Assert.NotNull(graph.ResolveByUrl("button")); + } +} diff --git a/tests/ShellDocs.Tests/ScaffoldingTests.cs b/tests/ShellDocs.Tests/ScaffoldingTests.cs index a27e6a8..623942b 100644 --- a/tests/ShellDocs.Tests/ScaffoldingTests.cs +++ b/tests/ShellDocs.Tests/ScaffoldingTests.cs @@ -1,22 +1,11 @@ -using ShellDocs.Core; using ShellDocs.Markdown; using ShellDocs.Templates; using Xunit; namespace ShellDocs.Tests; -// Smoke tests — verify each package's public surface loads and its trivial APIs behave. -// Real tests land branch-by-branch. public class ScaffoldingTests { - [Fact] - public void NavigationNode_HoldsRequiredFields() - { - var node = new NavigationNode { Url = "/docs/hello", Title = "Hello" }; - Assert.Equal("/docs/hello", node.Url); - Assert.Equal("Hello", node.Title); - } - [Fact] public void MarkdownPipelineFactory_ProducesUsablePipeline() {