Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions src/ShellDocs.Core/FrontmatterParser.cs
Original file line number Diff line number Diff line change
@@ -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<string, object?>(), source ?? "");
}

var lines = source.Split(LineSeparators, StringSplitOptions.None);
if (lines.Length < 2 || lines[0].TrimEnd() != "---")
{
return new ParsedDocument(new Dictionary<string, object?>(), 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<string, object?>(), source);
}

var yaml = string.Join("\n", lines[1..closingIndex]);
var body = string.Join("\n", lines[(closingIndex + 1)..]);

Dictionary<string, object?> frontmatter;
try
{
frontmatter = YamlDeserializer.Deserialize<Dictionary<string, object?>>(yaml)
?? new Dictionary<string, object?>();
}
catch (YamlDotNet.Core.YamlException)
{
frontmatter = new Dictionary<string, object?>();
}

return new ParsedDocument(frontmatter, body);
}

public static T? GetValue<T>(this IReadOnlyDictionary<string, object?> 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<string, object?> Frontmatter, string Body);
3 changes: 3 additions & 0 deletions src/ShellDocs.Core/Heading.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
namespace ShellDocs.Core;

public record Heading(int Level, string Text, string Id);
80 changes: 80 additions & 0 deletions src/ShellDocs.Core/MetaJson.cs
Original file line number Diff line number Diff line change
@@ -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<MetaJsonEntry> 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<MetaJson>(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<MetaJsonEntry> 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<MetaJsonEntry>
{
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<MetaJsonEntry>();
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.");
}
77 changes: 77 additions & 0 deletions src/ShellDocs.Core/NavigationGraph.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
namespace ShellDocs.Core;

public class NavigationGraph
{
public NavigationNode Root { get; }

private readonly Dictionary<string, NavigationNode> _byUrl;
private readonly List<NavigationNode> _flatPages;

public NavigationGraph(NavigationNode root)
{
Root = root;
_byUrl = new Dictionary<string, NavigationNode>(StringComparer.OrdinalIgnoreCase);
_flatPages = new List<NavigationNode>();
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<NavigationNode> GetBreadcrumb(NavigationNode node)
{
var chain = new List<NavigationNode>();
var current = node;
while (current is not null && current != Root)
{
chain.Add(current);
current = current.Parent;
}
chain.Reverse();
return chain;
}

public IEnumerable<NavigationNode> Flatten()
{
return FlattenFrom(Root);
}

private static IEnumerable<NavigationNode> 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;
}
}
165 changes: 165 additions & 0 deletions src/ShellDocs.Core/NavigationGraphBuilder.cs
Original file line number Diff line number Diff line change
@@ -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<NavigationNode> 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<NavigationNode>();
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<NavigationNode>();
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<string, NavigationNode> slugToNode,
Dictionary<string, (string folderPath, List<NavigationNode> 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<NavigationNode>();
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<string>("title") ?? TitleFromSlug(slug),
Description = parsed.Frontmatter.GetValue<string>("description"),
Category = parsed.Frontmatter.GetValue<string>("category"),
Order = parsed.Frontmatter.GetValue<int>("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<NavigationNode> 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;
}
}
Loading
Loading