Skip to content

Commit 290de05

Browse files
committed
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.
1 parent e73c5ec commit 290de05

6 files changed

Lines changed: 406 additions & 4 deletions

File tree

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
using YamlDotNet.Serialization;
2+
3+
namespace ShellDocs.Core;
4+
5+
public static class FrontmatterParser
6+
{
7+
private static readonly string[] LineSeparators = new[] { "\r\n", "\n" };
8+
private static readonly IDeserializer YamlDeserializer = new DeserializerBuilder().Build();
9+
10+
public static ParsedDocument Parse(string source)
11+
{
12+
if (string.IsNullOrEmpty(source))
13+
{
14+
return new ParsedDocument(new Dictionary<string, object?>(), source ?? "");
15+
}
16+
17+
var lines = source.Split(LineSeparators, StringSplitOptions.None);
18+
if (lines.Length < 2 || lines[0].TrimEnd() != "---")
19+
{
20+
return new ParsedDocument(new Dictionary<string, object?>(), source);
21+
}
22+
23+
var closingIndex = -1;
24+
for (var i = 1; i < lines.Length; i++)
25+
{
26+
if (lines[i].TrimEnd() == "---")
27+
{
28+
closingIndex = i;
29+
break;
30+
}
31+
}
32+
if (closingIndex == -1)
33+
{
34+
return new ParsedDocument(new Dictionary<string, object?>(), source);
35+
}
36+
37+
var yaml = string.Join("\n", lines[1..closingIndex]);
38+
var body = string.Join("\n", lines[(closingIndex + 1)..]);
39+
40+
Dictionary<string, object?> frontmatter;
41+
try
42+
{
43+
frontmatter = YamlDeserializer.Deserialize<Dictionary<string, object?>>(yaml)
44+
?? new Dictionary<string, object?>();
45+
}
46+
catch (YamlDotNet.Core.YamlException)
47+
{
48+
frontmatter = new Dictionary<string, object?>();
49+
}
50+
51+
return new ParsedDocument(frontmatter, body);
52+
}
53+
54+
public static T? GetValue<T>(this IReadOnlyDictionary<string, object?> frontmatter, string key)
55+
{
56+
if (!frontmatter.TryGetValue(key, out var raw) || raw is null) return default;
57+
if (raw is T typed) return typed;
58+
try
59+
{
60+
return (T)Convert.ChangeType(raw, typeof(T));
61+
}
62+
catch
63+
{
64+
return default;
65+
}
66+
}
67+
}
68+
69+
public record ParsedDocument(IReadOnlyDictionary<string, object?> Frontmatter, string Body);

src/ShellDocs.Core/Heading.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
namespace ShellDocs.Core;
2+
3+
public record Heading(int Level, string Text, string Id);

src/ShellDocs.Core/MetaJson.cs

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
using System.Text.Json;
2+
using System.Text.Json.Serialization;
3+
4+
namespace ShellDocs.Core;
5+
6+
public class MetaJson
7+
{
8+
[JsonPropertyName("title")]
9+
public string? Title { get; set; }
10+
11+
[JsonPropertyName("pages")]
12+
public List<MetaJsonEntry> Pages { get; set; } = new();
13+
14+
private static readonly JsonSerializerOptions Options = new()
15+
{
16+
PropertyNameCaseInsensitive = true,
17+
ReadCommentHandling = JsonCommentHandling.Skip,
18+
AllowTrailingCommas = true,
19+
Converters = { new MetaJsonEntryConverter() }
20+
};
21+
22+
public static MetaJson? Parse(string json)
23+
{
24+
if (string.IsNullOrWhiteSpace(json)) return null;
25+
return JsonSerializer.Deserialize<MetaJson>(json, Options);
26+
}
27+
}
28+
29+
public abstract class MetaJsonEntry { }
30+
public class MetaJsonPageRef : MetaJsonEntry { public required string Slug { get; init; } }
31+
public class MetaJsonDivider : MetaJsonEntry { }
32+
public class MetaJsonSubsection : MetaJsonEntry
33+
{
34+
public required string Title { get; init; }
35+
public List<MetaJsonEntry> Pages { get; init; } = new();
36+
}
37+
38+
/*
39+
Each entry in meta.json's "pages" array can be:
40+
- a string slug ("button")
41+
- the literal "---" for a section divider
42+
- an object { "title": "Data Display", "pages": [...] } for a subsection
43+
Custom converter dispatches on the JSON token type.
44+
*/
45+
internal class MetaJsonEntryConverter : JsonConverter<MetaJsonEntry>
46+
{
47+
public override MetaJsonEntry? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
48+
{
49+
if (reader.TokenType == JsonTokenType.String)
50+
{
51+
var value = reader.GetString() ?? "";
52+
return value == "---" ? new MetaJsonDivider() : new MetaJsonPageRef { Slug = value };
53+
}
54+
55+
if (reader.TokenType == JsonTokenType.StartObject)
56+
{
57+
using var doc = JsonDocument.ParseValue(ref reader);
58+
var root = doc.RootElement;
59+
var title = root.TryGetProperty("title", out var t) ? t.GetString() ?? "" : "";
60+
var pages = new List<MetaJsonEntry>();
61+
if (root.TryGetProperty("pages", out var p) && p.ValueKind == JsonValueKind.Array)
62+
{
63+
foreach (var el in p.EnumerateArray())
64+
{
65+
var elJson = el.GetRawText();
66+
var childReader = new Utf8JsonReader(System.Text.Encoding.UTF8.GetBytes(elJson));
67+
childReader.Read();
68+
var child = Read(ref childReader, typeof(MetaJsonEntry), options);
69+
if (child is not null) pages.Add(child);
70+
}
71+
}
72+
return new MetaJsonSubsection { Title = title, Pages = pages };
73+
}
74+
75+
throw new JsonException($"Unexpected token {reader.TokenType} in meta.json entry.");
76+
}
77+
78+
public override void Write(Utf8JsonWriter writer, MetaJsonEntry value, JsonSerializerOptions options)
79+
=> throw new NotSupportedException("Writing meta.json entries not supported.");
80+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
namespace ShellDocs.Core;
2+
3+
public class NavigationGraph
4+
{
5+
public NavigationNode Root { get; }
6+
7+
private readonly Dictionary<string, NavigationNode> _byUrl;
8+
private readonly List<NavigationNode> _flatPages;
9+
10+
public NavigationGraph(NavigationNode root)
11+
{
12+
Root = root;
13+
_byUrl = new Dictionary<string, NavigationNode>(StringComparer.OrdinalIgnoreCase);
14+
_flatPages = new List<NavigationNode>();
15+
Index(root);
16+
}
17+
18+
public NavigationNode? ResolveByUrl(string url)
19+
{
20+
var key = Normalize(url);
21+
return _byUrl.TryGetValue(key, out var node) ? node : null;
22+
}
23+
24+
public (NavigationNode? Prev, NavigationNode? Next) GetPrevNext(NavigationNode node)
25+
{
26+
var i = _flatPages.IndexOf(node);
27+
if (i < 0) return (null, null);
28+
var prev = i > 0 ? _flatPages[i - 1] : null;
29+
var next = i < _flatPages.Count - 1 ? _flatPages[i + 1] : null;
30+
return (prev, next);
31+
}
32+
33+
public IReadOnlyList<NavigationNode> GetBreadcrumb(NavigationNode node)
34+
{
35+
var chain = new List<NavigationNode>();
36+
var current = node;
37+
while (current is not null && current != Root)
38+
{
39+
chain.Add(current);
40+
current = current.Parent;
41+
}
42+
chain.Reverse();
43+
return chain;
44+
}
45+
46+
public IEnumerable<NavigationNode> Flatten()
47+
{
48+
return FlattenFrom(Root);
49+
}
50+
51+
private static IEnumerable<NavigationNode> FlattenFrom(NavigationNode node)
52+
{
53+
yield return node;
54+
foreach (var child in node.Children)
55+
{
56+
foreach (var n in FlattenFrom(child)) yield return n;
57+
}
58+
}
59+
60+
private void Index(NavigationNode node)
61+
{
62+
if (node.Kind == NodeKind.Page && !string.IsNullOrEmpty(node.Url))
63+
{
64+
_byUrl[Normalize(node.Url)] = node;
65+
_flatPages.Add(node);
66+
}
67+
foreach (var child in node.Children) Index(child);
68+
}
69+
70+
private static string Normalize(string url)
71+
{
72+
var s = url.Trim();
73+
if (!s.StartsWith('/')) s = "/" + s;
74+
if (s.Length > 1 && s.EndsWith('/')) s = s[..^1];
75+
return s;
76+
}
77+
}
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
namespace ShellDocs.Core;
2+
3+
public static class NavigationGraphBuilder
4+
{
5+
public static NavigationGraph Build(string contentRoot)
6+
{
7+
var abs = System.IO.Path.GetFullPath(contentRoot);
8+
if (!Directory.Exists(abs))
9+
{
10+
throw new DirectoryNotFoundException($"Content root not found: {contentRoot}");
11+
}
12+
13+
var rootNode = new NavigationNode
14+
{
15+
Title = "",
16+
Url = "/",
17+
Kind = NodeKind.Section,
18+
Path = abs
19+
};
20+
21+
var children = BuildFolder(abs, abs, urlPrefix: "");
22+
LinkChildren(rootNode, children);
23+
return new NavigationGraph(rootNode);
24+
}
25+
26+
private static List<NavigationNode> BuildFolder(string folder, string root, string urlPrefix)
27+
{
28+
var meta = ReadMeta(folder);
29+
var mdFiles = Directory.GetFiles(folder, "*.md", SearchOption.TopDirectoryOnly);
30+
var subfolders = Directory.GetDirectories(folder);
31+
32+
var slugToNode = mdFiles.ToDictionary(
33+
path => SlugFrom(path),
34+
path => BuildPage(path, root, urlPrefix),
35+
StringComparer.OrdinalIgnoreCase);
36+
37+
var folderNameToChildren = subfolders.ToDictionary(
38+
path => System.IO.Path.GetFileName(path),
39+
path => (folderPath: path, children: BuildFolder(path, root, CombineUrl(urlPrefix, System.IO.Path.GetFileName(path)))),
40+
StringComparer.OrdinalIgnoreCase);
41+
42+
// No meta.json: alphabetical ordering, subfolders inline as sections.
43+
if (meta is null)
44+
{
45+
var ordered = new List<NavigationNode>();
46+
foreach (var (name, tuple) in folderNameToChildren.OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase))
47+
{
48+
var section = new NavigationNode
49+
{
50+
Title = TitleFromFolderName(name),
51+
Kind = NodeKind.Section,
52+
Path = tuple.folderPath
53+
};
54+
LinkChildren(section, tuple.children);
55+
ordered.Add(section);
56+
}
57+
ordered.AddRange(slugToNode.Values.OrderBy(n => n.Order).ThenBy(n => n.Title, StringComparer.OrdinalIgnoreCase));
58+
return ordered;
59+
}
60+
61+
var result = new List<NavigationNode>();
62+
foreach (var entry in meta.Pages)
63+
{
64+
var node = ResolveEntry(entry, slugToNode, folderNameToChildren, root, urlPrefix);
65+
if (node is not null) result.Add(node);
66+
}
67+
return result;
68+
}
69+
70+
private static NavigationNode? ResolveEntry(
71+
MetaJsonEntry entry,
72+
Dictionary<string, NavigationNode> slugToNode,
73+
Dictionary<string, (string folderPath, List<NavigationNode> children)> folderNameToChildren,
74+
string root,
75+
string urlPrefix)
76+
{
77+
switch (entry)
78+
{
79+
case MetaJsonPageRef pageRef:
80+
if (slugToNode.TryGetValue(pageRef.Slug, out var page)) return page;
81+
if (folderNameToChildren.TryGetValue(pageRef.Slug, out var folder))
82+
{
83+
var section = new NavigationNode
84+
{
85+
Title = TitleFromFolderName(pageRef.Slug),
86+
Kind = NodeKind.Section,
87+
Path = folder.folderPath
88+
};
89+
LinkChildren(section, folder.children);
90+
return section;
91+
}
92+
return null;
93+
94+
case MetaJsonDivider:
95+
return new NavigationNode { Kind = NodeKind.Divider };
96+
97+
case MetaJsonSubsection sub:
98+
var subNode = new NavigationNode
99+
{
100+
Title = sub.Title,
101+
Kind = NodeKind.Section
102+
};
103+
var subChildren = new List<NavigationNode>();
104+
foreach (var e in sub.Pages)
105+
{
106+
var child = ResolveEntry(e, slugToNode, folderNameToChildren, root, urlPrefix);
107+
if (child is not null) subChildren.Add(child);
108+
}
109+
LinkChildren(subNode, subChildren);
110+
return subNode;
111+
112+
default:
113+
return null;
114+
}
115+
}
116+
117+
private static NavigationNode BuildPage(string path, string root, string urlPrefix)
118+
{
119+
var raw = File.ReadAllText(path);
120+
var parsed = FrontmatterParser.Parse(raw);
121+
var slug = SlugFrom(path);
122+
var url = slug == "index" ? EnsureLeadingSlash(urlPrefix) : CombineUrl(urlPrefix, slug);
123+
if (url == "") url = "/";
124+
125+
return new NavigationNode
126+
{
127+
Url = url,
128+
Title = parsed.Frontmatter.GetValue<string>("title") ?? TitleFromSlug(slug),
129+
Description = parsed.Frontmatter.GetValue<string>("description"),
130+
Category = parsed.Frontmatter.GetValue<string>("category"),
131+
Order = parsed.Frontmatter.GetValue<int>("order"),
132+
Path = path,
133+
Kind = NodeKind.Page
134+
};
135+
}
136+
137+
private static MetaJson? ReadMeta(string folder)
138+
{
139+
var metaPath = System.IO.Path.Combine(folder, "meta.json");
140+
return File.Exists(metaPath) ? MetaJson.Parse(File.ReadAllText(metaPath)) : null;
141+
}
142+
143+
private static void LinkChildren(NavigationNode parent, IReadOnlyList<NavigationNode> children)
144+
{
145+
foreach (var child in children) child.Parent = parent;
146+
parent.Children = children;
147+
}
148+
149+
private static string SlugFrom(string mdPath)
150+
=> System.IO.Path.GetFileNameWithoutExtension(mdPath);
151+
152+
private static string TitleFromSlug(string slug)
153+
=> string.Join(' ', slug.Split('-').Select(w => w.Length == 0 ? w : char.ToUpper(w[0]) + w[1..]));
154+
155+
private static string TitleFromFolderName(string name) => TitleFromSlug(name);
156+
157+
private static string EnsureLeadingSlash(string s) => s.StartsWith('/') ? s : "/" + s;
158+
159+
private static string CombineUrl(string prefix, string segment)
160+
{
161+
var p = prefix.TrimEnd('/');
162+
var s = segment.Trim('/');
163+
return string.IsNullOrEmpty(p) ? "/" + s : p + "/" + s;
164+
}
165+
}

0 commit comments

Comments
 (0)