Technical architecture. Consumers won't read this; contributors and future-you will.
For high-level design see DESIGN.md; for what ships when see ROADMAP.md.
┌────────────────────────────────────────────────────────────────────────┐
│ Build time │
│ │
│ content/**/*.md ──────► ShellDocs.Markdown ──────► Rendered pages │
│ │ (Markdig) │ │
│ │ │ │
│ ▼ ▼ │
│ Frontmatter ────► NavigationGraph ────► SearchIndexBuilder │
│ (ShellDocs.Core) (ShellDocs.Core) │
│ │ │
│ ▼ │
│ search-index.json │
│ (wwwroot/) │
└────────────────────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────────────────────┐
│ Runtime (browser, Blazor WASM) │
│ │
│ URL /docs/button │
│ │ │
│ ▼ │
│ Router ──► DocsPage ──► NavigationGraph.ResolveByUrl │
│ │ │ │
│ │ └──► NavigationNode │
│ │ │
│ ▼ │
│ MarkdownRenderer ──► MarkupString + component-slot list │
│ │ │
│ │ slots contain: <Callout Type="Info">…</Callout> │
│ │ │
│ ▼ │
│ DynamicComponent renders each slot via TypeRegistry │
└────────────────────────────────────────────────────────────────────────┘
Purpose: framework-agnostic building blocks. Zero dependency on Blazor.
Public API:
NavigationNode— POCO withUrl,Title,Description,Category,Order,Headings,Path,ChildrenNavigationGraph— tree wrapper withResolveByUrl(string),GetPrevNext(NavigationNode),GetBreadcrumb(NavigationNode),Flatten()NavigationGraphBuilder— takescontentRootpath +FrontmatterParser, returnsNavigationGraphFrontmatterParser— YamlDotNet-backed, returnsDictionary<string, object>SearchIndexEntry— POCO for one page's search entrySearchIndexBuilder— walksNavigationGraph+ rendered content, emitsIEnumerable<SearchIndexEntry>
Depends on: Markdig (for AST types only — no rendering), YamlDotNet, System.Text.Json
Why separate: static site generators, external tooling, or non-Blazor consumers could reuse the graph + index logic without pulling in Blazor.
Purpose: turn .md files into Razor-renderable output with typed component slots.
Public API:
MarkdownPipelineFactory— configures a Markdig pipeline with all custom extensionsMarkdownRenderer— takes markdown text or file path, returnsRenderedDocumentRenderedDocument { MarkupString Html, List<ComponentSlot> Slots, DocumentFrontmatter Frontmatter, List<Heading> Headings }ComponentSlot { Guid Id, Type ComponentType, Dictionary<string, object> Parameters, RenderFragment? ChildContent }TypeRegistry— mapsstring tagName→Type componentType; consumers register their types
Depends on: ShellDocs.Core, Markdig, YamlDotNet
Key design decisions:
- Two-pass rendering. First pass: Markdig turns markdown into HTML + placeholder
<span data-slot="{guid}">markers for component tags. Second pass:MarkdownContentcomponent walks its own DOM (or better: uses aRenderTreeBuilderon the AST directly) and swaps placeholders for<DynamicComponent>calls. razor:previewblocks are two things. Markdown fence with languagerazor:preview→ a<DocsTabs>slot with two children: a live-rendered<DynamicComponent>for the "Preview" tab and a<CodeBlock>for the "Code" tab.- Inline tags are strict.
<Button />in markdown must match a registered type. Unknown tags render as escaped text with a build-time warning (not a runtime crash).
Purpose: the UI. RCL — everyone's entry point.
Public API: the primitives listed in DESIGN.md — DocsLayout, DocsHeader, DocsSidebar, CodeBlock, SearchDialog, etc.
Depends on: ShellDocs.Core, ShellDocs.Markdown, ShellUI.Components
Key design decisions:
AddShellDocs()service extension. Consumer'sProgram.cscalls one method to registerNavigationGraph,MarkdownRenderer,TypeRegistry,IJSRuntimeinterop wrappers, current theme.- Cascading values everywhere.
NavigationGraph, current theme, and current page context are cascaded fromDocsLayoutso child components can consume them without prop-drilling. <DynamicComponent>for component rendering. Standard Blazor primitive — takes aType+Dictionary<string, object>params. Works with any registered component.- No JS beyond what's necessary. Shiki (highlighting), Motion One (animation fallback), IntersectionObserver (scroll-spy). Everything else pure Blazor.
Purpose: developer ergonomics.
Public API: commands, not a library.
shelldocs init [--yes] [--theme <name>]shelldocs new page <path>shelldocs new component-page <name>shelldocs dev [--port <n>]shelldocs build [--output <path>]shelldocs preview <component-name> [--variant <v>]
Depends on: System.CommandLine, Spectre.Console, ShellDocs.Templates
Key design decisions:
initis idempotent. Detects existing setup and skips already-done steps. Fresh consumers get everything; existing consumers can rerun to pull in new defaults.devisdotnet watch+ markdown watcher. Two file watchers:dotnet-watchhandles.razor/.csreload; a second watcher watchescontent/**/*.mdand pings the running app via a well-known endpoint to invalidate the navigation graph.buildisdotnet publish+ post-processing. Publish, then rewrite base-href, generate search index, copyindex.html→404.html. Same pattern the ShellUIfix/preview-appbranch uses.
Purpose: file content used by ShellDocs.CLI.
Public API: static classes with Content string properties, mirroring the pattern from ShellUI.Templates.
Depends on: nothing (or ShellDocs.Core for the NavigationNode POCO if templates need it).
Why separate from CLI: allows the templates to be updated (and versioned) independently. Also allows the CLI to be a small binary while templates carry the bulk of the bytes.
Purpose: extract API reference from XML doc comments.
Public API:
- MSBuild task
<ShellDocsXmlExtract Assembly="..." XmlDoc="..." Output="..." /> - Emits JSON per public type:
{ Name, Namespace, Summary, Properties: [{ Name, Type, Summary, Default }], Methods: [...] } <TypeTable Source="ShellUI.Components.Button" />reads the JSON at render time
Depends on: Microsoft.CodeAnalysis (Roslyn), System.Xml.Linq
Deferred to Phase 4. Hand-authored <TypeRow> unblocks Phase 2 shipping.
AddShellDocs(options) wires up everything.
public static IServiceCollection AddShellDocs(
this IServiceCollection services,
Action<ShellDocsOptions> configure)
{
var options = new ShellDocsOptions();
configure(options);
services.AddSingleton(options);
// Core — nav graph is built once at startup
services.AddSingleton<NavigationGraph>(sp =>
NavigationGraphBuilder.Build(options.ContentRoot));
// Markdown pipeline is singleton — construction is expensive
services.AddSingleton<MarkdownPipelineFactory>();
services.AddScoped<MarkdownRenderer>();
// Type registry is populated during Register* calls
services.AddSingleton<TypeRegistry>(sp =>
{
var registry = new TypeRegistry();
foreach (var type in options.RegisteredComponents) registry.Register(type);
return registry;
});
// Theme applied as cascading value in DocsLayout
services.AddSingleton(options.Theme);
return services;
}ShellDocsOptions — plain POCO with fluent-friendly config:
public class ShellDocsOptions
{
public string ContentRoot { get; set; } = "content";
public string SiteName { get; set; } = "";
public string? GitHubRepo { get; set; }
public ShellDocsTheme Theme { get; set; } = ShellDocsTheme.Shadcn;
public List<Type> RegisteredComponents { get; } = new();
public bool EnableSearch { get; set; } = true;
public string SearchIndexPath { get; set; } = "search-index.json";
// ... more knobs
public ShellDocsOptions RegisterComponent<T>() where T : ComponentBase
{
RegisteredComponents.Add(typeof(T));
return this;
}
}Fluent API supports method chaining in Program.cs.
Three extensions register on the Markdig pipeline:
1. Frontmatter extension
Uses Markdig.Extensions.Yaml.YamlFrontMatterExtension (built-in). Wraps its output as DocumentFrontmatter in the RenderedDocument.
2. razor:preview fence extension
Extends FencedCodeBlockRenderer — inspects the info string. If it starts with razor:preview, replaces the standard code-block output with a placeholder <span data-shelldocs-preview="{guid}"></span> and adds a PreviewSlot to the rendered document.
At render time, MarkdownContent walks its markup and for each data-shelldocs-preview span, injects a <DocsTabs> with the preview slot.
3. Inline component tag extension
Custom InlineParser on Markdig — matches <TagName params /> at the block or inline level. Rejects if TagName isn't in TypeRegistry. Emits a placeholder <span data-shelldocs-component="{guid}"></span> and adds a ComponentSlot to the rendered document.
Same render-time swap logic as razor:preview.
Generating Razor source code from markdown at build time is possible but adds tooling complexity. Markdown-to-HTML + slot list at render time keeps everything in the runtime and lets us change the wrapper components without rebuilding the source.
Trade-off: DOM walking at render time has a per-page cost (~1ms for a large page). Acceptable.
NavigationGraphBuilder.Build(contentRoot):
- Recursively walk
contentRoot - For each
.mdfile: parse frontmatter, create aNavigationNodewithUrl = path minus root + filename, populate from frontmatter - For each folder: check for
meta.json, build children in specified order; fall back to alphabetical - Cross-link
ParentandChildren - Compute derived properties:
NextNode,PreviousNode(in flatten order) - Return
NavigationGraphroot
Cache result — rebuild only when dev mode detects a change.
NavigationGraph.ResolveByUrl("/docs/button"):
- Walk the tree matching path segments
- O(depth) — usually 2–3 segments
- Return
nullif not found (page shows 404)
NavigationGraph.GetPrevNext(node):
- Precomputed at build; O(1) lookup
NavigationGraph.GetBreadcrumb(node):
- Walk up
Parentchain; O(depth)
{
"title": "Components",
"pages": [
"button",
"input",
"---",
{
"title": "Data Display",
"pages": ["table", "card", "badge"]
}
]
}- Strings: page slug matches filename minus
.md "---": renders as a section divider in the sidebar- Objects: nested subsection with its own title and pages
- Nested folders can have their own
meta.jsonfor further nesting
Unknown page slugs (typo in meta.json) emit a build-time warning but don't crash.
SearchIndexBuilder.Build(graph, renderer):
For each node in the graph:
- Render the page's markdown (via
MarkdownRenderer) - Extract all
<h2>and<h3>headings (already inRenderedDocument.Headings) - Extract first 200 chars of body per heading (for excerpt)
- Build
SearchIndexEntry { Url, Title, Description, Category, Headings, Excerpt }
Serialize the list to search-index.json, written to output wwwroot/.
SearchDialog component on first open (lazy):
HttpClient.GetFromJsonAsync<SearchIndexEntry[]>("search-index.json")- Store in memory for subsequent opens
On each keystroke:
- Fuzzy match against
Title+Headings.Text+Excerpt - Score = Levenshtein + prefix bonus + heading-match bonus
- Top N results rendered inline in the modal
No debounce needed for docs sites this size. ~500 entries client-filtered in <1ms.
For 1000+ pages, the client-side fuzzy match starts to lag on slow devices. Escape hatch: options.SearchProvider = SearchProvider.Orama swaps to an Orama-backed backend. Not v1; escape hatch design only.
Standard Blazor primitive — takes Type + IReadOnlyDictionary<string, object>?.
MarkdownContent component:
@foreach (var slot in Document.Slots)
{
<span data-slot="@slot.Id">
<DynamicComponent Type="@slot.ComponentType" Parameters="@slot.Parameters">
@slot.ChildContent
</DynamicComponent>
</span>
}Then the plain HTML with data-slot placeholders is emitted alongside; the DOM ends up interleaved. Details in the markdown pipeline notes.
Parameter serialization: frontmatter values are strings from YAML. TypeRegistry inspects each component's [Parameter] properties to know the target type and coerces (int, bool, enum, string). Complex types (records, DTOs) need JSON literals in the markdown.
Each theme is a package that ships:
- A CSS file with custom properties (
--color-bg,--color-accent,--font-heading,--radius, etc.) - Optional component style overrides (
.docs-sidebar-item.active { … }) - Registered via
options.Theme = ShellDocsTheme.Fuma
DocsLayout applies the theme's CSS by injecting the stylesheet link into the <HeadContent> or by cascading a ThemeContext that child components consume.
Every custom property is overridable in the consumer's wwwroot/*.css. Cascade order: theme preset → consumer CSS. Standard shadcn escape hatch.
Every component parameter is public. Consumer can wrap <DocsSidebar> in a component of their own with different behaviour — same override pattern as shadcn's copy-and-edit philosophy.
shelldocs build:
dotnet publish -c Release -o <output>— Blazor's WASM AOT publish- Run
SearchIndexBuilderon the content, writesearch-index.jsontooutput/wwwroot/ - Rewrite
<base href="/" />inindex.htmlto<base href="/<basePath>/" />ifbasePathsupplied - Copy
index.html→404.html(GH Pages SPA fallback) - Copy any content-referenced static assets (images embedded in markdown)
- Exit
Optionally, in a future version: prerender each route to a static .html file for SEO / first-paint. Requires Blazor's prerendering support — feasible in Server hosting mode, harder in WASM. Deferred.
Two processes:
1. dotnet watch run on the consumer project. Hot-reloads on .razor / .cs changes.
2. Markdown file watcher (FileSystemWatcher on content/**/*.md) — spawns from the same CLI process. On change:
- Rebuild
NavigationGraph - POST to a well-known endpoint on the running app (
/_shelldocs/reload-graph) - The endpoint invokes a
NavigationGraph-refresh service (registered byAddShellDocs()in dev mode)
Alternative: SignalR channel between CLI and app. Overkill for v1.
Same three-layer approach as ShellUI:
1. Unit tests (ShellDocs.Tests)
- xUnit
- Markdig extensions in isolation
NavigationGraphBuilderon synthetic content treesTypeRegistrycoercionSearchIndexBuilderoutput shape
2. Template compile tests
- Every CLI template's
Contentis Roslyn-parsed to catch escape-quote regressions
3. Live↔template sync tests
- The scaffold generated by
shelldocs initmatches what the CLI templates say it should
4. E2E in CI
shelldocs inita Blazor WASM project in a temp dirshelldocs buildit- Verify output has expected files, valid HTML, search index present
Priorities:
- Base ShellDocs.Components: target <100KB gzipped
- With Shiki full theme set: +~2MB (opt-out via config to a Prism-based highlighter, <50KB)
- With Motion One fallback: +~10KB gzipped
Every JS interop file gets budget scrutiny.
- All primitives ship with correct ARIA roles + labels
- Keyboard nav complete (Tab / Shift+Tab / Enter / Esc across all interactives)
- Focus management on modal open/close
- Skip-to-content link in
DocsLayout prefers-reduced-motionrespected everywhere animations run
Not v1. Design left open by making content routing extension-friendly — a future ShellDocs.I18n package could add locale-aware content resolution without breaking NavigationGraph's shape.
- A markdown editor. Consumer authors in whatever they use (VSCode, Rider, whatever).
- A CMS. Content is
.mdfiles in a git repo. That's the interface. - A server backend. ShellDocs is static-only; the search index is client-side.
- A hosted service. No shelldocs.dev SaaS; it's a NuGet package family.
- A design system. ShellUI is that. ShellDocs uses ShellUI; doesn't compete.
Scope discipline. These are all things fumadocs also didn't build — and it stayed lean and usable.