From 2ea1c67dc0204f1faefb91f56319f302ad17c454 Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Sat, 29 Aug 2026 19:04:38 +0200 Subject: [PATCH 1/4] feat(components): AutoTypeTable renders props from reflection --- .../Content/AutoTypeTable.razor | 148 ++++++++++++++++++ .../Content/XmlDocIndex.cs | 69 ++++++++ 2 files changed, 217 insertions(+) create mode 100644 src/ShellDocs.Components/Content/AutoTypeTable.razor create mode 100644 src/ShellDocs.Components/Content/XmlDocIndex.cs diff --git a/src/ShellDocs.Components/Content/AutoTypeTable.razor b/src/ShellDocs.Components/Content/AutoTypeTable.razor new file mode 100644 index 0000000..4570524 --- /dev/null +++ b/src/ShellDocs.Components/Content/AutoTypeTable.razor @@ -0,0 +1,148 @@ +@namespace ShellDocs.Components.Content +@using System.ComponentModel +@using System.Reflection +@using Microsoft.AspNetCore.Components +@using ShellDocs.Markdown +@inject TypeRegistry Registry + +@if (_target is null) +{ +
+ + + + +
Unknown component: @Component
+
+} +else +{ +
+ + + + + + + + + + + @if (_rows.Count == 0) + { + + } + else + { + @foreach (var row in _rows) + { + + + + + + + } + } + +
PropTypeDefaultDescription
No [Parameter] props on @Component.
+ @row.Name + @if (row.Required) { Required } + @row.Type + @if (!string.IsNullOrEmpty(row.Default)) { @row.Default } + else { } + + @if (!string.IsNullOrEmpty(row.Description)) { @row.Description } + else { } +
+
+} + +@code { + [Parameter, EditorRequired] public string? Component { get; set; } + + private Type? _target; + private List _rows = new(); + + protected override void OnParametersSet() + { + _target = Component is null ? null : Registry.Resolve(Component); + _rows = _target is null ? new() : BuildRows(_target); + } + + private static List BuildRows(Type target) + { + var rows = new List(); + object? instance = null; + try { instance = Activator.CreateInstance(target); } catch { } + + foreach (var prop in target.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if (prop.GetCustomAttribute() is null) continue; + + var name = prop.Name; + var type = FormatType(prop.PropertyType); + var required = prop.GetCustomAttribute() is not null; + var def = ReadDefault(prop, instance); + var desc = XmlDocIndex.SummaryFor(prop); + rows.Add(new TypeRowInfo(name, type, def, desc, required)); + } + return rows; + } + + // Compact C#-ish rendering: `string`, `string?`, `int`, `bool`, `RenderFragment?`, + // `IReadOnlyDictionary?`. Enough signal for docs without dragging + // the full reflection namespace tree. + private static string FormatType(Type t) + { + var underlying = Nullable.GetUnderlyingType(t); + if (underlying is not null) return FormatType(underlying) + "?"; + if (t == typeof(string)) return "string"; + if (t == typeof(bool)) return "bool"; + if (t == typeof(int)) return "int"; + if (t == typeof(long)) return "long"; + if (t == typeof(double)) return "double"; + if (t == typeof(decimal)) return "decimal"; + if (!t.IsValueType && t.Name == "String") return "string"; + + var isRefNullable = !t.IsValueType; + var name = ShortName(t); + if (t.IsGenericType) + { + var args = string.Join(", ", t.GetGenericArguments().Select(FormatType)); + name = ShortName(t.GetGenericTypeDefinition()).Split('`')[0] + "<" + args + ">"; + } + return isRefNullable ? name + "?" : name; + } + + private static string ShortName(Type t) => t.Name; + + private static string? ReadDefault(PropertyInfo prop, object? instance) + { + // Explicit [DefaultValue("...")] wins. + var dva = prop.GetCustomAttribute(); + if (dva is not null) return FormatValue(dva.Value); + + if (instance is null) return null; + object? value; + try { value = prop.GetValue(instance); } + catch { return null; } + if (value is null) return null; + + // RenderFragment defaults are opaque — render as em-dash by returning null. + if (value is RenderFragment) return null; + var formatted = FormatValue(value); + // A `string.Empty` from an initializer isn't a meaningful default to show. + if (formatted == "\"\"") return null; + return formatted; + } + + private static string FormatValue(object? v) + { + if (v is null) return "null"; + if (v is string s) return "\"" + s + "\""; + if (v is bool b) return b ? "true" : "false"; + if (v.GetType().IsEnum) return v.GetType().Name + "." + v; + return v.ToString() ?? ""; + } +} diff --git a/src/ShellDocs.Components/Content/XmlDocIndex.cs b/src/ShellDocs.Components/Content/XmlDocIndex.cs new file mode 100644 index 0000000..4be7b5d --- /dev/null +++ b/src/ShellDocs.Components/Content/XmlDocIndex.cs @@ -0,0 +1,69 @@ +using System.Collections.Concurrent; +using System.Reflection; +using System.Xml.Linq; + +namespace ShellDocs.Components.Content; + +// Loads `.xml` sidecars once per assembly and exposes summaries by +// member id (`P:Namespace.Type.Prop`). Missing file → empty index (returns null). +internal static class XmlDocIndex +{ + private static readonly ConcurrentDictionary> _cache = new(); + + public static string? SummaryFor(PropertyInfo prop) + { + var declaring = prop.DeclaringType; + if (declaring is null) return null; + var index = LoadFor(declaring.Assembly); + var key = "P:" + declaring.FullName + "." + prop.Name; + return index.TryGetValue(key, out var s) ? s : null; + } + + private static IReadOnlyDictionary LoadFor(Assembly asm) + { + return _cache.GetOrAdd(asm, a => + { + var xmlPath = Path.ChangeExtension(a.Location, ".xml"); + if (string.IsNullOrEmpty(xmlPath) || !File.Exists(xmlPath)) + return new Dictionary(0); + try + { + var doc = XDocument.Load(xmlPath); + var members = doc.Root?.Element("members")?.Elements("member") ?? Enumerable.Empty(); + var map = new Dictionary(StringComparer.Ordinal); + foreach (var m in members) + { + var name = m.Attribute("name")?.Value; + var summary = m.Element("summary")?.Value; + if (name is null || summary is null) continue; + map[name] = CollapseWhitespace(summary); + } + return map; + } + catch + { + return new Dictionary(0); + } + }); + } + + private static string CollapseWhitespace(string raw) + { + var trimmed = raw.Trim(); + var sb = new System.Text.StringBuilder(trimmed.Length); + var lastWasSpace = false; + foreach (var c in trimmed) + { + if (char.IsWhiteSpace(c)) + { + if (!lastWasSpace) { sb.Append(' '); lastWasSpace = true; } + } + else + { + sb.Append(c); + lastWasSpace = false; + } + } + return sb.ToString(); + } +} From a3fdd5c1fbfb98a750a41ac323d0359bf8e5fb4f Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Sat, 29 Aug 2026 19:05:22 +0200 Subject: [PATCH 2/4] feat(renderer): route named child tags into matching RenderFragment params --- .../Content/SlotRenderer.cs | 82 ++++++++++++++++- tests/ShellDocs.Tests/SlotRendererTests.cs | 91 +++++++++++++++++++ 2 files changed, 171 insertions(+), 2 deletions(-) create mode 100644 tests/ShellDocs.Tests/SlotRendererTests.cs diff --git a/src/ShellDocs.Components/Content/SlotRenderer.cs b/src/ShellDocs.Components/Content/SlotRenderer.cs index 04655c3..be571a8 100644 --- a/src/ShellDocs.Components/Content/SlotRenderer.cs +++ b/src/ShellDocs.Components/Content/SlotRenderer.cs @@ -1,6 +1,7 @@ using System.Globalization; using System.Reflection; using System.Text; +using System.Text.RegularExpressions; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Rendering; using ShellDocs.Markdown; @@ -82,17 +83,94 @@ public static IDictionary BuildParameters( } if (!string.IsNullOrWhiteSpace(childContentRaw)) { - dict["ChildContent"] = FromMarkup(renderer, childContentRaw); + // Route direct-child tags whose name matches a RenderFragment + // param (other than ChildContent) into that named slot. Any + // remaining text becomes ChildContent. Lets authors write + // Body and have 's + // inner content routed to Alert.Icon instead of being flattened + // into the ChildContent stream. + var slotNames = props + .Where(kv => kv.Key != "ChildContent" && typeof(RenderFragment).IsAssignableFrom(kv.Value.PropertyType)) + .Select(kv => kv.Key) + .ToHashSet(StringComparer.Ordinal); + + var remaining = childContentRaw; + if (slotNames.Count > 0) + { + foreach (var slotName in slotNames) + { + var extracted = ExtractNamedSlot(remaining, slotName); + if (extracted.Content is not null) + { + dict[slotName] = FromMarkup(renderer, extracted.Content); + remaining = extracted.Remaining; + } + } + } + + if (!string.IsNullOrWhiteSpace(remaining)) + dict["ChildContent"] = FromMarkup(renderer, remaining); + /* If the target declares a ChildContentSource [Parameter] (as ComponentPreview does for reconstructing its source view), pass the raw markup through unchanged in addition to the - RenderFragment above. */ + RenderFragments above. */ if (props.ContainsKey("ChildContentSource")) dict["ChildContentSource"] = childContentRaw; } return dict; } + // Finds `...` (balanced) or `` in `text` + // and returns its inner content + `text` with that occurrence removed. + // Only the first occurrence is extracted; multi-instance named slots + // aren't a common pattern. + private static (string? Content, string Remaining) ExtractNamedSlot(string text, string tagName) + { + var open = Regex.Match(text, $@"<{Regex.Escape(tagName)}(?\s[^>]*?)?\s*(?/)?>"); + if (!open.Success) return (null, text); + + if (open.Groups["self"].Success) + { + // Self-closing → empty content, remove the tag. + var head = text.Substring(0, open.Index); + var tail = text.Substring(open.Index + open.Length); + return ("", head + tail); + } + + // Find matching close, tracking nested opens of the same name. + var closeName = Regex.Escape(tagName); + var scanFrom = open.Index + open.Length; + var depth = 1; + var openRe = new Regex($@"<{closeName}(\s[^>]*?)?\s*(?/)?>"); + var closeRe = new Regex($@""); + while (depth > 0) + { + var nextOpen = openRe.Match(text, scanFrom); + var nextClose = closeRe.Match(text, scanFrom); + if (!nextClose.Success) return (null, text); + if (nextOpen.Success && nextOpen.Index < nextClose.Index) + { + if (!nextOpen.Groups["self"].Success) depth++; + scanFrom = nextOpen.Index + nextOpen.Length; + } + else + { + depth--; + if (depth == 0) + { + var innerStart = open.Index + open.Length; + var inner = text.Substring(innerStart, nextClose.Index - innerStart); + var head = text.Substring(0, open.Index); + var tail = text.Substring(nextClose.Index + nextClose.Length); + return (inner, head + tail); + } + scanFrom = nextClose.Index + nextClose.Length; + } + } + return (null, text); + } + private static readonly Dictionary> _propCache = new(); internal static Dictionary GetParameterProps(Type t) diff --git a/tests/ShellDocs.Tests/SlotRendererTests.cs b/tests/ShellDocs.Tests/SlotRendererTests.cs new file mode 100644 index 0000000..b30c414 --- /dev/null +++ b/tests/ShellDocs.Tests/SlotRendererTests.cs @@ -0,0 +1,91 @@ +using System.Reflection; +using Microsoft.AspNetCore.Components; +using ShellDocs.Markdown; +using Xunit; + +namespace ShellDocs.Tests; + +// Named-slot routing behavior: child tags matching a target component's +// [Parameter] RenderFragment prop names should route into that param instead +// of being flattened into ChildContent. +public class SlotRendererTests +{ + public class Alert : ComponentBase + { + [Parameter] public string? Title { get; set; } + [Parameter] public RenderFragment? Icon { get; set; } + [Parameter] public RenderFragment? Footer { get; set; } + [Parameter] public RenderFragment? ChildContent { get; set; } + } + + private static readonly MethodInfo BuildParametersMethod = LoadBuildParameters(); + + private static MethodInfo LoadBuildParameters() + { + var asm = Assembly.Load("ShellDocs.Components"); + var t = asm.GetType("ShellDocs.Components.Content.SlotRenderer", throwOnError: true)!; + return t.GetMethod("BuildParameters", BindingFlags.Public | BindingFlags.Static)!; + } + + private static IDictionary BuildParameters(Type target, IReadOnlyDictionary attrs, string? childRaw) + { + var renderer = new MarkdownRenderer(); + return (IDictionary)BuildParametersMethod.Invoke(null, new object?[] { renderer, target, attrs, childRaw })!; + } + + [Fact] + public void BuildParameters_RoutesNamedSlotIntoRenderFragmentParam() + { + var attrs = new Dictionary { ["Title"] = "Heads up" }; + var raw = "Body text."; + + var dict = BuildParameters(typeof(Alert), attrs, raw); + + Assert.Equal("Heads up", dict["Title"]); + Assert.IsType(dict["Icon"]); + Assert.IsType(dict["ChildContent"]); + Assert.False(dict.ContainsKey("Footer")); + } + + [Fact] + public void BuildParameters_RoutesMultipleNamedSlots() + { + var raw = "Middle text
Small print
"; + var dict = BuildParameters(typeof(Alert), new Dictionary(), raw); + + Assert.IsType(dict["Icon"]); + Assert.IsType(dict["Footer"]); + Assert.IsType(dict["ChildContent"]); + } + + [Fact] + public void BuildParameters_ChildContentOnly_WhenNoNamedSlotTagsPresent() + { + var dict = BuildParameters(typeof(Alert), new Dictionary(), "Just body text."); + + Assert.IsType(dict["ChildContent"]); + Assert.False(dict.ContainsKey("Icon")); + Assert.False(dict.ContainsKey("Footer")); + } + + [Fact] + public void BuildParameters_NoChildContent_WhenAllRawIsConsumedByNamedSlots() + { + var raw = "
Only slots.
"; + var dict = BuildParameters(typeof(Alert), new Dictionary(), raw); + + Assert.IsType(dict["Icon"]); + Assert.IsType(dict["Footer"]); + Assert.False(dict.ContainsKey("ChildContent")); + } + + [Fact] + public void BuildParameters_SelfClosingNamedSlot_Extracted() + { + var raw = "Body."; + var dict = BuildParameters(typeof(Alert), new Dictionary(), raw); + + Assert.IsType(dict["Icon"]); + Assert.IsType(dict["ChildContent"]); + } +} From c4246dbef49777c2d206ba8a030a3b2aa0f59f02 Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Sat, 29 Aug 2026 19:06:19 +0200 Subject: [PATCH 3/4] feat(build): --site-url emits sitemap.xml, robots.txt, and og meta --- src/ShellDocs.CLI/Commands/BuildCommand.cs | 84 ++++++++++- src/ShellDocs.CLI/Program.cs | 9 +- src/ShellDocs.Components/ShellDocsOptions.cs | 4 + tests/ShellDocs.Tests/BuildCommandTests.cs | 141 ++++++++++++++++++- 4 files changed, 230 insertions(+), 8 deletions(-) diff --git a/src/ShellDocs.CLI/Commands/BuildCommand.cs b/src/ShellDocs.CLI/Commands/BuildCommand.cs index 4c3d13f..8bac5bb 100644 --- a/src/ShellDocs.CLI/Commands/BuildCommand.cs +++ b/src/ShellDocs.CLI/Commands/BuildCommand.cs @@ -7,7 +7,7 @@ namespace ShellDocs.CLI.Commands; internal static class BuildCommand { - public static int Run(string dir, string output, string? baseHref, bool spaFallback) + public static int Run(string dir, string output, string? baseHref, bool spaFallback, string? siteUrl = null) { var root = Path.GetFullPath(dir); var csproj = FindCsproj(root); @@ -27,11 +27,13 @@ public static int Run(string dir, string output, string? baseHref, bool spaFallb var outputAbs = Path.GetFullPath(Path.Combine(root, output)); var publishStage = Path.Combine(root, "obj", "shelldocs-publish"); + var normalizedSiteUrl = siteUrl?.TrimEnd('/'); AnsiConsole.MarkupLine($"[dim]shelldocs build →[/] [cyan]{Path.GetFileName(csproj)}[/]"); AnsiConsole.MarkupLine($"[dim]output:[/] [cyan]{outputAbs}[/]"); - if (baseHref is not null) AnsiConsole.MarkupLine($"[dim]base href:[/] [cyan]{baseHref}[/]"); - if (spaFallback) AnsiConsole.MarkupLine("[dim]spa fallback:[/] [cyan]index.html → 404.html[/]"); + if (baseHref is not null) AnsiConsole.MarkupLine($"[dim]base href:[/] [cyan]{baseHref}[/]"); + if (spaFallback) AnsiConsole.MarkupLine("[dim]spa fallback:[/] [cyan]index.html → 404.html[/]"); + if (normalizedSiteUrl is not null) AnsiConsole.MarkupLine($"[dim]site url:[/] [cyan]{normalizedSiteUrl}[/]"); AnsiConsole.WriteLine(); var publishExit = RunPublish(csproj, publishStage); @@ -51,9 +53,10 @@ public static int Run(string dir, string output, string? baseHref, bool spaFallb // Home ("/") is served by Home.razor and isn't part of NavigationGraph. var urls = new List { "/" }; + NavigationGraph? graph = null; try { - var graph = NavigationGraphBuilder.Build(contentRoot); + graph = NavigationGraphBuilder.Build(contentRoot); urls.AddRange(graph.AllUrls); } catch (Exception ex) @@ -99,6 +102,14 @@ public static int Run(string dir, string output, string? baseHref, bool spaFallb } } + if (normalizedSiteUrl is not null && graph is not null) + { + WriteSitemap(outputAbs, normalizedSiteUrl, urls); + WriteRobots(outputAbs, normalizedSiteUrl); + var ogCount = InjectOgMeta(outputAbs, normalizedSiteUrl, graph); + AnsiConsole.MarkupLine($"[dim]seo:[/] sitemap.xml + robots.txt + og meta on [cyan]{ogCount}[/] page(s)"); + } + try { Directory.Delete(publishStage, recursive: true); } catch { } AnsiConsole.WriteLine(); @@ -181,4 +192,69 @@ internal static int RewriteBaseHrefInAllHtml(string outputDir, string baseHref) var matches = Directory.GetFiles(dir, "*.csproj", SearchOption.TopDirectoryOnly); return matches.Length == 0 ? null : matches[0]; } + + internal static void WriteSitemap(string outputDir, string siteUrl, IReadOnlyList urls) + { + var sb = new System.Text.StringBuilder(); + sb.AppendLine(""); + sb.AppendLine(""); + foreach (var url in urls.Distinct(StringComparer.OrdinalIgnoreCase)) + { + var abs = siteUrl + (url.StartsWith('/') ? url : "/" + url); + sb.Append(" ").Append(System.Net.WebUtility.HtmlEncode(abs)).AppendLine(""); + } + sb.AppendLine(""); + File.WriteAllText(Path.Combine(outputDir, "sitemap.xml"), sb.ToString()); + } + + internal static void WriteRobots(string outputDir, string siteUrl) + { + var body = $"User-agent: *{Environment.NewLine}Allow: /{Environment.NewLine}Sitemap: {siteUrl}/sitemap.xml{Environment.NewLine}"; + File.WriteAllText(Path.Combine(outputDir, "robots.txt"), body); + } + + // Injects og:title / og:description / og:url / og:type into each prerendered + // HTML file's , using titles + descriptions from the nav graph. Skips + // pages the graph doesn't know about (e.g. root "/" home page). + internal static int InjectOgMeta(string outputDir, string siteUrl, NavigationGraph graph) + { + var count = 0; + foreach (var url in graph.AllUrls) + { + var node = graph.ResolveByUrl(url); + if (node is null) continue; + var htmlPath = UrlToHtmlPath(outputDir, url); + if (!File.Exists(htmlPath)) continue; + + var html = File.ReadAllText(htmlPath); + var absUrl = siteUrl + (url.StartsWith('/') ? url : "/" + url); + var meta = BuildOgBlock(node.Title, node.Description, absUrl); + + var headClose = html.IndexOf("", StringComparison.OrdinalIgnoreCase); + if (headClose < 0) continue; + var patched = html.Insert(headClose, meta); + File.WriteAllText(htmlPath, patched); + count++; + } + return count; + } + + private static string BuildOgBlock(string? title, string? description, string absUrl) + { + var sb = new System.Text.StringBuilder(); + sb.Append(" ").Append(Environment.NewLine); + sb.Append(" ").Append(Environment.NewLine); + if (!string.IsNullOrWhiteSpace(title)) + sb.Append(" ").Append(Environment.NewLine); + if (!string.IsNullOrWhiteSpace(description)) + sb.Append(" ").Append(Environment.NewLine); + return sb.ToString(); + } + + private static string UrlToHtmlPath(string outputDir, string url) + { + var trimmed = url.Trim('/'); + if (string.IsNullOrEmpty(trimmed)) return Path.Combine(outputDir, "index.html"); + return Path.Combine(outputDir, Path.Combine(trimmed.Split('/')), "index.html"); + } } diff --git a/src/ShellDocs.CLI/Program.cs b/src/ShellDocs.CLI/Program.cs index 6531322..e2283e8 100644 --- a/src/ShellDocs.CLI/Program.cs +++ b/src/ShellDocs.CLI/Program.cs @@ -131,16 +131,21 @@ private static Command CreateBuildCommand() { Description = "Copy index.html → 404.html so client-side routes survive on GH Pages." }; + var siteUrl = new Option("--site-url") + { + Description = "Absolute site URL (e.g. \"https://shelldocs.dev\"). Enables sitemap.xml, robots.txt, and og: meta tags." + }; var cmd = new Command("build", "Produce a static site ready for GH Pages / Cloudflare / S3.") { - dir, output, baseHref, spaFallback + dir, output, baseHref, spaFallback, siteUrl }; cmd.SetAction(pr => BuildCommand.Run( pr.GetValue(dir) ?? Directory.GetCurrentDirectory(), pr.GetValue(output) ?? "publish", pr.GetValue(baseHref), - pr.GetValue(spaFallback))); + pr.GetValue(spaFallback), + pr.GetValue(siteUrl))); return cmd; } diff --git a/src/ShellDocs.Components/ShellDocsOptions.cs b/src/ShellDocs.Components/ShellDocsOptions.cs index f4a81e1..f61ea63 100644 --- a/src/ShellDocs.Components/ShellDocsOptions.cs +++ b/src/ShellDocs.Components/ShellDocsOptions.cs @@ -10,6 +10,10 @@ public class ShellDocsOptions public string SiteName { get; set; } = ""; public string? SiteTagline { get; set; } public string? GitHubRepo { get; set; } + // Absolute base URL, e.g. "https://shelldocs.dev". Consumed by + // `shelldocs build` to emit sitemap.xml, robots.txt, and og:url meta. + // Skip those artifacts silently when unset. + public string? SiteUrl { get; set; } public string? LogoLight { get; set; } public string? LogoDark { get; set; } diff --git a/tests/ShellDocs.Tests/BuildCommandTests.cs b/tests/ShellDocs.Tests/BuildCommandTests.cs index dd876b7..0ac2d7a 100644 --- a/tests/ShellDocs.Tests/BuildCommandTests.cs +++ b/tests/ShellDocs.Tests/BuildCommandTests.cs @@ -1,4 +1,5 @@ using System.Reflection; +using ShellDocs.Core; using Xunit; namespace ShellDocs.Tests; @@ -8,6 +9,9 @@ public class BuildCommandTests : IDisposable private readonly string _tempDir; private readonly MethodInfo _rewrite; private readonly MethodInfo _copy; + private readonly MethodInfo _writeSitemap; + private readonly MethodInfo _writeRobots; + private readonly MethodInfo _injectOg; public BuildCommandTests() { @@ -18,8 +22,11 @@ public BuildCommandTests() .FirstOrDefault(a => a.GetName().Name == "shelldocs") ?? Assembly.Load("shelldocs"); var type = cli.GetType("ShellDocs.CLI.Commands.BuildCommand", throwOnError: true)!; - _rewrite = type.GetMethod("RewriteBaseHrefInAllHtml", BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public)!; - _copy = type.GetMethod("CopyDirectoryMerging", BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public)!; + _rewrite = type.GetMethod("RewriteBaseHrefInAllHtml", BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public)!; + _copy = type.GetMethod("CopyDirectoryMerging", BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public)!; + _writeSitemap = type.GetMethod("WriteSitemap", BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public)!; + _writeRobots = type.GetMethod("WriteRobots", BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public)!; + _injectOg = type.GetMethod("InjectOgMeta", BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public)!; } public void Dispose() @@ -33,6 +40,26 @@ private int RewriteBaseHrefInAllHtml(string outputDir, string href) => private void CopyDirectoryMerging(string source, string dest) => _copy.Invoke(null, new object[] { source, dest }); + private void WriteSitemap(string outputDir, string siteUrl, IReadOnlyList urls) => + _writeSitemap.Invoke(null, new object[] { outputDir, siteUrl, urls }); + + private void WriteRobots(string outputDir, string siteUrl) => + _writeRobots.Invoke(null, new object[] { outputDir, siteUrl }); + + private int InjectOgMeta(string outputDir, string siteUrl, NavigationGraph graph) => + (int)_injectOg.Invoke(null, new object[] { outputDir, siteUrl, graph })!; + + // NavigationNode.Children/Parent have `internal set` — bypass via reflection + // so tests can build a graph without exposing the setters or standing up a + // temp content directory. + private static readonly PropertyInfo _childrenProp = typeof(NavigationNode).GetProperty("Children")!; + private static readonly PropertyInfo _parentProp = typeof(NavigationNode).GetProperty("Parent")!; + private static void LinkChildren(NavigationNode parent, params NavigationNode[] children) + { + _childrenProp.SetValue(parent, children); + foreach (var c in children) _parentProp.SetValue(c, parent); + } + [Theory] [InlineData("", "/repo/", "")] [InlineData("", "/repo/", "")] @@ -129,4 +156,114 @@ public void CopyDirectoryMerging_CopiesMissingFilesEvenWhenSomeExist() Assert.Equal("dst-a", File.ReadAllText(Path.Combine(dst, "a.txt"))); Assert.Equal("src-b", File.ReadAllText(Path.Combine(dst, "b.txt"))); } + + [Fact] + public void WriteSitemap_ProducesValidUrlSet() + { + WriteSitemap(_tempDir, "https://example.com", new[] { "/", "/docs/introduction", "/docs/cli/build" }); + + var xml = File.ReadAllText(Path.Combine(_tempDir, "sitemap.xml")); + Assert.Contains("", xml); + Assert.Contains("", xml); + Assert.Contains("https://example.com/", xml); + Assert.Contains("https://example.com/docs/introduction", xml); + Assert.Contains("https://example.com/docs/cli/build", xml); + Assert.Contains("", xml); + } + + [Fact] + public void WriteSitemap_DeduplicatesUrls() + { + WriteSitemap(_tempDir, "https://example.com", new[] { "/", "/", "/docs/x", "/docs/x" }); + + var xml = File.ReadAllText(Path.Combine(_tempDir, "sitemap.xml")); + Assert.Equal(2, System.Text.RegularExpressions.Regex.Matches(xml, "").Count); + } + + [Fact] + public void WriteRobots_IncludesSitemapReference() + { + WriteRobots(_tempDir, "https://example.com"); + var txt = File.ReadAllText(Path.Combine(_tempDir, "robots.txt")); + + Assert.Contains("User-agent: *", txt); + Assert.Contains("Allow: /", txt); + Assert.Contains("Sitemap: https://example.com/sitemap.xml", txt); + } + + [Fact] + public void InjectOgMeta_AddsOgTagsBeforeHeadClose() + { + var pagePath = Path.Combine(_tempDir, "docs", "intro", "index.html"); + Directory.CreateDirectory(Path.GetDirectoryName(pagePath)!); + File.WriteAllText(pagePath, "tb"); + + var pageNode = new NavigationNode + { + Url = "/docs/intro", + Title = "Introduction", + Description = "What ShellDocs is.", + Kind = NodeKind.Page, + }; + var root = new NavigationNode { Url = "/", Kind = NodeKind.Section }; + LinkChildren(root, pageNode); + var graph = new NavigationGraph(root); + + var count = InjectOgMeta(_tempDir, "https://example.com", graph); + + Assert.Equal(1, count); + var html = File.ReadAllText(pagePath); + Assert.Contains("", html); + Assert.Contains("", html); + Assert.Contains("", html); + Assert.Contains("", html); + // Injected before , not after. + var ogIdx = html.IndexOf("og:type", StringComparison.Ordinal); + var closeIdx = html.IndexOf("", StringComparison.Ordinal); + Assert.True(ogIdx > 0 && ogIdx < closeIdx, "og:type meta must appear before "); + } + + [Fact] + public void InjectOgMeta_SkipsMissingHtmlFilesGracefully() + { + var pageNode = new NavigationNode + { + Url = "/nowhere", + Title = "Ghost", + Description = "Not on disk.", + Kind = NodeKind.Page, + }; + var root = new NavigationNode { Url = "/", Kind = NodeKind.Section }; + LinkChildren(root, pageNode); + var graph = new NavigationGraph(root); + + var count = InjectOgMeta(_tempDir, "https://example.com", graph); + + Assert.Equal(0, count); + } + + [Fact] + public void InjectOgMeta_EncodesSpecialCharacters() + { + var pagePath = Path.Combine(_tempDir, "p", "index.html"); + Directory.CreateDirectory(Path.GetDirectoryName(pagePath)!); + File.WriteAllText(pagePath, ""); + + var pageNode = new NavigationNode + { + Url = "/p", + Title = "AT&T ", + Description = "Uses & and <", + Kind = NodeKind.Page, + }; + var root = new NavigationNode { Url = "/", Kind = NodeKind.Section }; + LinkChildren(root, pageNode); + var graph = new NavigationGraph(root); + + InjectOgMeta(_tempDir, "https://example.com", graph); + + var html = File.ReadAllText(pagePath); + Assert.Contains("AT&T <spec>", html); + Assert.Contains("Uses & and <", html); + } } From ac08939729c6a9d23c47744161b6fb296e9151e4 Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Sat, 29 Aug 2026 19:07:27 +0200 Subject: [PATCH 4/4] chore: bump to 0.1.6-alpha, changelog --- CHANGELOG.md | 27 +++++++++++++++++++++++++++ Directory.Build.props | 2 +- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41ce398..e8f31fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,33 @@ All notable changes to ShellDocs land here. Format follows [Keep a Changelog](ht ## [Unreleased] +## [0.1.6-alpha] — 2026-08-22 + +Three authoring / SEO features that stack together to make writing per-component docs and shipping a public site substantially less manual. + +### Added + +- **`` primitive.** Renders a full props table for any registered component by reflecting on its `[Parameter]` properties. Reads type (compact C#-ish rendering including nullability and generic args), default (from `[DefaultValue]` or from instantiating the type and reading the property), required flag (from `[EditorRequired]`), and description (from XML doc `` on the property, loaded from the sidecar `.xml` next to the DLL). Replaces the "hand-copy every prop into ``" pattern that every consumer was doing today. Existing `` / `` stay for cases where hand curation is preferable. +- **Named `RenderFragment` slots in `razor:preview` fences and inline component tags.** Direct-child tags whose name matches a target component's `[Parameter] RenderFragment` prop now route into that named slot instead of being flattened into `ChildContent`. Authors can finally write compositional previews: + ```razor + + + Body text. +
Small print.
+
+ ``` + and have `` render into `Alert.Icon`, `