Skip to content
48 changes: 36 additions & 12 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,22 +164,46 @@ Ships to `ShellDocs.Components`.
- `PrevNextNav` — auto-derived from nav graph adjacency, rendered at page bottom
- `DocsBreadcrumb` — composes ShellUI's `<Breadcrumb>` with docs presets

### `feat/content-primitives`
### `feat/content-primitives` — shipped
Ships to `ShellDocs.Components`.

- `DocsTabs` — multi-tab code containers (`npm` / `yarn` / `pnpm` / `standalone` / `bash` presets)
- `Callout` — Info / Warning / Tip / Danger box (may reuse ShellUI `<Alert>` with docs styling wrapper)
- `LinkCard` — card-shaped link with title / description / icon for "Next steps" grids
- `FileTree` — static folder / file visualization
- `Steps` — vertical numbered steps for onboarding flows
- `<CodeGroup>` / `<CodeTab>` — multi-tab code containers with cross-page `SyncKey` sync (`npm` / `pnpm` / `yarn`, etc.)
- `<Callout>` (Info / Warning / Danger / Tip) with per-variant icon
- `<Card>` / `<CardGrid>` / `<LinkCard>` — responsive card grid + anchor-shaped link card
- `<FileTree>` / `<FileTreeItem>` — recursive project-layout diagram with `IsFolder`, `Highlight`, `Comment`
- `<Steps>` / `<Step>` — CSS-counter numbered ordered list with a badge-on-rail spine
- Preview-frame overhaul: dropped tabs for a fumadocs-style stacked preview + collapsed code teaser with "View Code" expand — both panels stay mounted, killing the whole class of tab-switch state loss
- `SlotRenderer` gains recursive nested-markup rendering (`ChildContentRaw` threading, `Dedent` for Markdig 4-space-indent trap) and per-property type coercion for `bool` / `int` / enum attribute values

### `feat/api-reference-primitives`
### `feat/api-reference-primitives` — shipped
Ships to `ShellDocs.Components`.

- `TypeTable` — props table with `<TypeRow Name Type Default Description />` child components
- `ComponentPreview` — live component render by name + prop dictionary, source-view toggle
- Uses `<DynamicComponent>` for runtime component rendering
- Type registry from `ShellDocs.Markdown` reused
- `<TypeTable>` / `<TypeRow Name Type Default Description Required />` — hand-authored props reference table via `CascadingValue` registration
- `<ComponentPreview Component="Foo" ...props>` — declarative-prop cousin of `razor:preview`; resolves target by name through `TypeRegistry`, forwards attrs via `CaptureUnmatchedValues` with the same per-type coercion `SlotRenderer` uses, reconstructs source view from the resolved prop dict (self-closing form when no body)
- `SlotRenderer.Coerce` + `GetParameterProps` bumped to `internal` so `ComponentPreview` can drive the same conversion path

### ✅ `feat/consumer-registration-dx` — shipped
Ships to `ShellDocs.Components` + `ShellDocs.Templates` + `ShellDocs.CLI` + `ShellDocs.Markdown`.

**Registration (`ShellDocs.Components`)**
- `ShellDocsOptions.RegisterComponentsFromAssembly<TMarker>()` — assembly-scan overload that walks the marker's assembly for public, concrete, non-generic `ComponentBase` subclasses and registers each. Kills the "hand-type `RegisterComponent<T>()` for every ShellUI component" tax for consumers.
- `RegisterComponentsFromAssembly(Assembly, Func<Type, bool>?)` — explicit form with a filter predicate for finer control (namespace narrowing, opt-in subsets, etc.)
- `[ShellDocsIgnore]` attribute — opt-out marker for public components that shouldn't be reachable from markdown authoring (e.g. render-machinery components that live in the same assembly)
- `RegisterComponent(Type)` runtime overload alongside the existing generic form
- `RegisterComponent<T>(string tagName)` + `RegisterComponent(Type, string tagName)` — alias overloads that expose a component under a different markdown-facing tag (e.g. `<Btn>` for `ShellUI.Button`); backed by a per-type `ComponentAliases` dictionary that `BuildTypeRegistry` consults before falling back to `type.Name`
- **Dogfooded on ourselves:** `AddShellDocs` now scans `ShellDocs.Components.Content` via this API instead of the old explicit-list `RegisterComponent<Callout>(); .RegisterComponent<Card>(); …` block, so a new primitive dropped under `Content/` auto-appears without a maintainer edit to `ServiceCollectionExtensions.cs`. `MarkdownContent` and `PreviewFrame` opt out via `[ShellDocsIgnore]`.

**Content scaffolding (`ShellDocs.Templates` + `ShellDocs.CLI`)**
- `shelldocs add <template> <name> [--dir] [--force]` — CLI command that scaffolds a starter `.md` page from a template into `content/`. Templates:
- `component <Name>` → `content/docs/components/<slug>.md` — frontmatter + intro + `razor:preview` block + empty `<TypeTable>` + Notes
- `guide <slug>` → `content/docs/guides/<slug>.md` — frontmatter + intro + `<Steps>` skeleton
- `page <slug>` → `content/docs/<slug>.md` — blank frontmatter + H1
- Slugifies PascalCase inputs (`MyBigCard` → `my-big-card.md`) and TitleCases kebab inputs (`getting-started` → "Getting Started"). Refuses to overwrite unless `--force`.
- `PageTemplates` static class in `ShellDocs.Templates` holds the three template bodies — same access pattern as the existing `StarterPageTemplate`.
- Replaces the placeholder `new` command stub in `Program.cs`.

**Authoring fix (`ShellDocs.Markdown`)**
- `SlotExtractor.ReplaceComponentTags` no longer `.Trim()`s the raw child content of inline component tags. The Trim was stripping the first line's indent and defeating `SlotRenderer.Dedent` — Markdig then interpreted the remaining 4-space-indented lines as an indented code block. Symptom was the same "literal `<pre>` around placeholder divs" bug that had already been fixed for `razor:preview` fences; the inline-tag code path was still hitting it.

### `feat/animation-polish`
Ships to `ShellDocs.Components`.
Expand All @@ -197,7 +221,7 @@ Ships to `ShellDocs.Components`.
- Version bump, notes, NuGet push
- Ready for dogfood via shellui.dev

**Milestone:** ShellDocs is feature-complete for a full-featured docs site. Real content authoring can begin.
**Milestone:** ShellDocs is feature-complete for a full-featured docs site. Real content authoring can begin. Remaining Phase 2 work: `feat/animation-polish` (nice-to-have) and the `0.2.0-alpha` NuGet cut.

---

Expand Down
113 changes: 113 additions & 0 deletions src/ShellDocs.CLI/Commands/AddCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
using System.Text.RegularExpressions;
using ShellDocs.Templates;
using Spectre.Console;

namespace ShellDocs.CLI.Commands;

/* `shelldocs add <template> <name>` — scaffolds a starter markdown page from a
template into the project's content root. Templates:
component — content/docs/components/<slug>.md (razor:preview + TypeTable skeleton)
guide — content/docs/guides/<slug>.md (Steps skeleton)
page — content/docs/<slug>.md (blank frontmatter + title)

Slugifies component-style names ("MyBigCard" → "my-big-card"), preserves
kebab/snake input verbatim, and refuses to overwrite an existing file unless
--force is passed. */
internal static class AddCommand
{
public static int Run(string template, string name, string dir, bool force)
{
var templateKey = (template ?? "").Trim().ToLowerInvariant();
if (string.IsNullOrEmpty(name))
{
AnsiConsole.MarkupLine("[red]error:[/] name is required.");
AnsiConsole.MarkupLine("[dim]usage: shelldocs add <component|guide|page> <name>[/]");
return 1;
}

var contentRoot = ResolveContentRoot(dir);
if (contentRoot is null)
{
AnsiConsole.MarkupLine($"[red]error:[/] no [yellow]content/[/] directory found under [yellow]{dir}[/].");
AnsiConsole.MarkupLine("[dim]run this from your docs project root, or use --dir to point at it.[/]");
return 1;
}

var (subDir, displayName, slug, body) = templateKey switch
{
"component" => ("docs/components", DisplayName(name), Slugify(name), PageTemplates.ComponentPage(DisplayName(name))),
"guide" => ("docs/guides", TitleCase(name), Slugify(name), PageTemplates.GuidePage(TitleCase(name))),
"page" => ("docs", TitleCase(name), Slugify(name), PageTemplates.BlankPage(TitleCase(name))),
_ => (null, "", "", "")!
};

if (subDir is null)
{
AnsiConsole.MarkupLine($"[red]error:[/] unknown template [yellow]{template}[/].");
AnsiConsole.MarkupLine("[dim]expected one of: [cyan]component[/], [cyan]guide[/], [cyan]page[/].[/]");
return 1;
}

var targetDir = Path.Combine(contentRoot, subDir);
Directory.CreateDirectory(targetDir);
var targetFile = Path.Combine(targetDir, slug + ".md");

if (File.Exists(targetFile) && !force)
{
AnsiConsole.MarkupLine($"[red]error:[/] [yellow]{PrettyPath(targetFile)}[/] already exists. Pass [cyan]--force[/] to overwrite.");
return 1;
}

File.WriteAllText(targetFile, body);
AnsiConsole.MarkupLine($"[green]created[/] [cyan]{PrettyPath(targetFile)}[/]");
AnsiConsole.MarkupLine($"[dim]edit the frontmatter + TODOs, then reload the dev server.[/]");
return 0;
}

private static string? ResolveContentRoot(string dir)
{
var abs = Path.GetFullPath(dir);
var candidate = Path.Combine(abs, "content");
if (Directory.Exists(candidate)) return candidate;
return null;
}

/* "MyBigCard" → "my-big-card"; "getting-started" → "getting-started";
"Getting Started" → "getting-started"; drops non-alphanumeric except '-' */
private static string Slugify(string raw)
{
var withDashes = Regex.Replace(raw.Trim(), @"(?<=[a-z0-9])(?=[A-Z])", "-");
withDashes = Regex.Replace(withDashes, @"[\s_]+", "-");
withDashes = Regex.Replace(withDashes, @"[^A-Za-z0-9\-]", "");
withDashes = Regex.Replace(withDashes, @"-{2,}", "-").Trim('-');
return withDashes.ToLowerInvariant();
}

/* Component name stays PascalCase for the display (matches how <Button> etc.
are referenced in razor:preview). If input already contains spaces, keep
the first-letter-uppercase form. */
private static string DisplayName(string raw)
{
var trimmed = raw.Trim();
if (trimmed.Contains(' ') || trimmed.Contains('-') || trimmed.Contains('_'))
return TitleCase(trimmed);
return char.IsUpper(trimmed[0]) ? trimmed : char.ToUpper(trimmed[0]) + trimmed[1..];
}

/* "getting-started" → "Getting Started"; "MyGuide" → "My Guide" */
private static string TitleCase(string raw)
{
var spaced = Regex.Replace(raw.Trim(), @"[-_]+", " ");
spaced = Regex.Replace(spaced, @"(?<=[a-z0-9])(?=[A-Z])", " ");
var parts = spaced.Split(' ', StringSplitOptions.RemoveEmptyEntries);
return string.Join(' ', parts.Select(p => char.ToUpper(p[0]) + p[1..].ToLowerInvariant()));
}

private static string PrettyPath(string abs)
{
var cwd = Directory.GetCurrentDirectory();
return abs.StartsWith(cwd, StringComparison.OrdinalIgnoreCase)
? abs[(cwd.Length + 1)..].Replace('\\', '/')
: abs.Replace('\\', '/');
}
}
23 changes: 15 additions & 8 deletions src/ShellDocs.CLI/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ private static int Main(string[] args)
{
var root = new RootCommand("ShellDocs — the docs framework for .NET.");
root.Subcommands.Add(CreateInitCommand());
root.Subcommands.Add(CreateNewCommand());
root.Subcommands.Add(CreateAddCommand());
root.Subcommands.Add(CreateDevCommand());
root.Subcommands.Add(CreateBuildCommand());
root.Subcommands.Add(CreatePreviewCommand());
Expand Down Expand Up @@ -69,15 +69,22 @@ private static Command CreateInitCommand()
return cmd;
}

private static Command CreateNewCommand()
private static Command CreateAddCommand()
{
var kind = new Argument<string>("kind") { Description = "Template kind: page, component-page." };
var name = new Argument<string>("name") { Description = "File name for the new page." };
var cmd = new Command("new", "Scaffold a new doc page from a template.") { kind, name };
cmd.SetAction(pr =>
var template = new Argument<string>("template") { Description = "Template: component, guide, page." };
var name = new Argument<string>("name") { Description = "Name of the new page (PascalCase for component, kebab-case for guide/page)." };
var dir = new Option<string>("--dir")
{
AnsiConsole.MarkupLine($"[yellow]shelldocs new {pr.GetValue(kind)} {pr.GetValue(name)}[/] — not yet implemented (feat/cli-init).");
});
Description = "Project directory (default: current dir).",
DefaultValueFactory = _ => Directory.GetCurrentDirectory()
};
var force = new Option<bool>("--force") { Description = "Overwrite an existing file with the same name." };
var cmd = new Command("add", "Scaffold a new doc page from a template into content/.") { template, name, dir, force };
cmd.SetAction(pr => AddCommand.Run(
pr.GetValue(template) ?? "",
pr.GetValue(name) ?? "",
pr.GetValue(dir) ?? Directory.GetCurrentDirectory(),
pr.GetValue(force)));
return cmd;
}

Expand Down
1 change: 1 addition & 0 deletions src/ShellDocs.Components/Content/MarkdownContent.razor
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
@attribute [ShellDocsIgnore]
@inject MarkdownRenderer Renderer
@inject IJSRuntime JS

Expand Down
1 change: 1 addition & 0 deletions src/ShellDocs.Components/Content/PreviewFrame.razor
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
@using ShellDocs.Markdown
@attribute [ShellDocsIgnore]
@inject MarkdownRenderer Renderer

<div class="preview-frame @(_expanded ? "expanded" : "collapsed")">
Expand Down
2 changes: 1 addition & 1 deletion src/ShellDocs.Components/Content/TypeTable.razor
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
<code>@row.Name</code>
@if (row.Required)
{
<span class="type-table-required" title="Required">required</span>
<span class="type-table-required" title="Required">Required</span>
}
</td>
<td class="type-table-type">
Expand Down
8 changes: 2 additions & 6 deletions src/ShellDocs.Components/Content/TypeTable.razor.css
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,7 @@
padding: 0.65rem 0.9rem;
color: var(--muted-foreground);
font-weight: 500;
font-size: 0.75rem;
letter-spacing: 0.02em;
text-transform: uppercase;
font-size: 0.78rem;
white-space: nowrap;
}

Expand Down Expand Up @@ -60,10 +58,8 @@
background: color-mix(in oklch, var(--destructive, oklch(0.577 0.245 27.325)) 12%, transparent);
color: var(--destructive, oklch(0.577 0.245 27.325));
border-radius: calc(var(--radius) - 4px);
font-size: 0.68rem;
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
white-space: nowrap;
}

Expand Down
24 changes: 8 additions & 16 deletions src/ShellDocs.Components/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,22 +20,14 @@ public static IServiceCollection AddShellDocs(this IServiceCollection services,
services.AddScoped<SidebarCollapseState>();
services.AddScoped<CodeGroupSyncState>();

// Auto-register the shipped content primitives so `razor:preview` blocks
// in markdown can reference <Callout>, <Card>, <Steps>, <FileTree> etc.
// without the consumer calling RegisterComponent<T>() themselves.
options.RegisterComponent<Callout>();
options.RegisterComponent<Card>();
options.RegisterComponent<CardGrid>();
options.RegisterComponent<LinkCard>();
options.RegisterComponent<Steps>();
options.RegisterComponent<Step>();
options.RegisterComponent<FileTree>();
options.RegisterComponent<FileTreeItem>();
options.RegisterComponent<CodeGroup>();
options.RegisterComponent<CodeTab>();
options.RegisterComponent<TypeTable>();
options.RegisterComponent<TypeRow>();
options.RegisterComponent<ComponentPreview>();
/* Auto-register the shipped content primitives so `razor:preview` blocks
in markdown can reference <Callout>, <Card>, <Steps>, <FileTree> etc.
without the consumer calling RegisterComponent<T>() themselves.
Dogfoods RegisterComponentsFromAssembly against our own Content
namespace — new primitives added under Content/ auto-appear here
without a maintainer edit to this file. Internal render machinery
(MarkdownContent, PreviewFrame) opts out via [ShellDocsIgnore]. */
options.RegisterComponentsFromAssembly<Callout>(t => t.Namespace == "ShellDocs.Components.Content");

services.AddSingleton<TypeRegistry>(_ => options.BuildTypeRegistry());
services.AddSingleton<MarkdownRenderer>(sp => new MarkdownRenderer(sp.GetRequiredService<TypeRegistry>()));
Expand Down
10 changes: 10 additions & 0 deletions src/ShellDocs.Components/ShellDocsIgnoreAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace ShellDocs.Components;

/* Marker attribute — put this on a public ComponentBase-derived type to keep it
out of ShellDocsOptions.RegisterComponentsFromAssembly(...) scans. Use for
internal-shaped components that happen to be `public` for testing / other
assemblies but shouldn't be reachable from markdown authoring. */
[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
public sealed class ShellDocsIgnoreAttribute : Attribute
{
}
Loading
Loading