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
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

- **`<AutoTypeTable Component="Name" />` 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 `<summary>` on the property, loaded from the sidecar `<Assembly>.xml` next to the DLL). Replaces the "hand-copy every prop into `<TypeRow>`" pattern that every consumer was doing today. Existing `<TypeTable>` / `<TypeRow>` 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
<Alert Title="Heads up">
<Icon><svg>…</svg></Icon>
Body text.
<Footer>Small print.</Footer>
</Alert>
```
and have `<Icon>` render into `Alert.Icon`, `<Footer>` into `Alert.Footer`, and the leftover text into `Alert.ChildContent`. Unblocks previewing every ShellUI-shaped component that uses named slots (Alert, Card, Sheet, Drawer, Tabs, Accordion, Dialog).
- **`shelldocs build --site-url <https://example.com>` emits sitemap.xml + robots.txt + `og:*` meta tags on every prerendered page.** Sitemap enumerates every URL discovered by the prerender walk (visible + hidden). Robots allows all and points at the sitemap. Per-page `<meta property="og:title">` / `og:description` / `og:url` / `og:type` injected before `</head>` from the nav-graph node's title + description. All three artifacts are silently skipped when `--site-url` is unset — no accidental broken sitemaps in local dev builds.
- **`ShellDocsOptions.SiteUrl`** — declared symmetrically with the CLI flag for consumers who prefer configuration-side declaration (currently informational; `shelldocs build --site-url` remains the mechanism the CLI consumes).

### Changed

- **`SlotRenderer.BuildParameters`** now walks the target's `[Parameter]` props once to extract `RenderFragment` names before falling back to the pre-fix `ChildContent`-only behavior. Backwards compatible — no named-slot tags in child content → identical output to before.

### Notes

`XmlDocIndex` caches per-assembly load once; concurrent consumer requests share the parsed dictionary. XML docs are optional — a component whose assembly ships without an `.xml` file just renders em-dash descriptions.

## [0.1.5-alpha] — 2026-08-22

The `shelldocs build` output is now a real static site. Previously it copied `publish/wwwroot/` 1:1 — fine on paper, useless in practice for a Blazor Server scaffold, which produces no `index.html`, no client-side runtime, nothing a static host can serve at the root URL. Deploying the output to GH Pages / Cloudflare Pages / Netlify silently returned a blank shell. Now every URL the site knows about is prerendered at build time and the framework's static assets are merged on top.
Expand Down
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@

<!-- Package metadata (applies to any project with IsPackable=true) -->
<PropertyGroup>
<Version>0.1.5-alpha</Version>
<Version>0.1.6-alpha</Version>
<Authors>ShellUI</Authors>
<Company>ShellUI</Company>
<Copyright>Copyright © 2026 ShellUI</Copyright>
Expand Down
84 changes: 80 additions & 4 deletions src/ShellDocs.CLI/Commands/BuildCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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<string> { "/" };
NavigationGraph? graph = null;
try
{
var graph = NavigationGraphBuilder.Build(contentRoot);
graph = NavigationGraphBuilder.Build(contentRoot);
urls.AddRange(graph.AllUrls);
}
catch (Exception ex)
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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<string> urls)
{
var sb = new System.Text.StringBuilder();
sb.AppendLine("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
sb.AppendLine("<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">");
foreach (var url in urls.Distinct(StringComparer.OrdinalIgnoreCase))
{
var abs = siteUrl + (url.StartsWith('/') ? url : "/" + url);
sb.Append(" <url><loc>").Append(System.Net.WebUtility.HtmlEncode(abs)).AppendLine("</loc></url>");
}
sb.AppendLine("</urlset>");
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 <head>, 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("</head>", 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(" <meta property=\"og:type\" content=\"article\" />").Append(Environment.NewLine);
sb.Append(" <meta property=\"og:url\" content=\"").Append(System.Net.WebUtility.HtmlEncode(absUrl)).Append("\" />").Append(Environment.NewLine);
if (!string.IsNullOrWhiteSpace(title))
sb.Append(" <meta property=\"og:title\" content=\"").Append(System.Net.WebUtility.HtmlEncode(title)).Append("\" />").Append(Environment.NewLine);
if (!string.IsNullOrWhiteSpace(description))
sb.Append(" <meta property=\"og:description\" content=\"").Append(System.Net.WebUtility.HtmlEncode(description)).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");
}
}
9 changes: 7 additions & 2 deletions src/ShellDocs.CLI/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string?>("--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;
}

Expand Down
148 changes: 148 additions & 0 deletions src/ShellDocs.Components/Content/AutoTypeTable.razor
Original file line number Diff line number Diff line change
@@ -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)
{
<div class="type-table-wrap">
<table class="type-table">
<tbody>
<tr><td colspan="4" class="type-table-empty">Unknown component: <code>@Component</code></td></tr>
</tbody>
</table>
</div>
}
else
{
<div class="type-table-wrap">
<table class="type-table">
<thead>
<tr>
<th class="type-table-col-name">Prop</th>
<th class="type-table-col-type">Type</th>
<th class="type-table-col-default">Default</th>
<th class="type-table-col-desc">Description</th>
</tr>
</thead>
<tbody>
@if (_rows.Count == 0)
{
<tr><td colspan="4" class="type-table-empty">No [Parameter] props on <code>@Component</code>.</td></tr>
}
else
{
@foreach (var row in _rows)
{
<tr>
<td class="type-table-name">
<code>@row.Name</code>
@if (row.Required) { <span class="type-table-required" title="Required">Required</span> }
</td>
<td class="type-table-type"><code>@row.Type</code></td>
<td class="type-table-default">
@if (!string.IsNullOrEmpty(row.Default)) { <code>@row.Default</code> }
else { <span class="type-table-muted">—</span> }
</td>
<td class="type-table-desc">
@if (!string.IsNullOrEmpty(row.Description)) { @row.Description }
else { <span class="type-table-muted">—</span> }
</td>
</tr>
}
}
</tbody>
</table>
</div>
}

@code {
[Parameter, EditorRequired] public string? Component { get; set; }

private Type? _target;
private List<TypeRowInfo> _rows = new();

protected override void OnParametersSet()
{
_target = Component is null ? null : Registry.Resolve(Component);
_rows = _target is null ? new() : BuildRows(_target);
}

private static List<TypeRowInfo> BuildRows(Type target)
{
var rows = new List<TypeRowInfo>();
object? instance = null;
try { instance = Activator.CreateInstance(target); } catch { }

foreach (var prop in target.GetProperties(BindingFlags.Public | BindingFlags.Instance))
{
if (prop.GetCustomAttribute<ParameterAttribute>() is null) continue;

var name = prop.Name;
var type = FormatType(prop.PropertyType);
var required = prop.GetCustomAttribute<EditorRequiredAttribute>() 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<string, object>?`. 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<DefaultValueAttribute>();
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() ?? "";
}
}
Loading
Loading