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
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,31 @@ All notable changes to ShellDocs land here. Format follows [Keep a Changelog](ht

## [Unreleased]

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

Second dogfood batch. `0.1.3-alpha` pinned the sidebar footer to a fixed slot but only under the desktop Sidebar variant — footer still floated mid-sidebar on TopNav / mobile drawer / short-tree cases.

### Fixed

- **Sidebar footer now pins to the bottom in every layout context.** The `0.1.3-alpha` fix only worked in the desktop Sidebar variant — the `flex: 1; min-height: 0` sizing that lets the nav fill its slot was scoped to `.docs-shell-sidebar .docs-sidebar-slot > nav` inside a `@media (min-width: 1024px)` block. On the TopNav variant, the mobile drawer, or any Sidebar site whose tree is shorter than the slot, the nav still collapsed to intrinsic content size and the footer (GitHub link + theme toggle) sat wherever the tree ended, mid-sidebar. Moved `flex: 1; min-height: 0` onto `.docs-sidebar` itself and made `.docs-sidebar-slot` `display: flex; flex-direction: column` in every context. Footer pins hard to the bottom regardless of variant / viewport / item count.
- **`razor:preview` fences with an unknown outer tag now render a visible error in the frame instead of falling back to a plain code block silently.** Previously `SlotExtractor.TryBuildPreviewSlot` returned `null` when the fence's first tag didn't resolve to a registered component; the fence rendered as regular fenced code with only a build-log warning. Authors chasing "why isn't my icon rendering" would hunt for a nonexistent component bug. Now the same case emits a `PreviewSlot` with `ComponentType = null` and an `Error` message; `PreviewFrame` renders a red-tinted error panel in the render region naming the unknown tag and pointing at `o.RegisterComponent<T>()` / `o.RegisterComponentsFromAssembly<TMarker>()`. Build-log warning still emitted. `PreviewSlot.ComponentType` is now nullable — technically a source-breaking change for callers pattern-matching on it, though external consumers of that type are ~none in the alpha window. [SHELLDOCS_FIXES.md #4]

### Added

- **`RegisterComponentsFromAssembly<TMarker>(string namespacePrefix)` overload.** Registering only components under a specific namespace from a big assembly no longer needs a `Func<Type, bool>` — the common "register everything under my Components namespace" case reads as:
```csharp
o.RegisterComponentsFromAssembly<Marker>("ShellIcons.Icons");
```
instead of the lambda form. The `Func` overload stays for anything more complex.
Comment on lines +18 to +22
- **`shelldocs init` scaffolded `Program.cs` now surfaces the `LayoutVariant` knob.** Commented-out `// o.LayoutVariant = DocsLayoutVariant.Sidebar;` line right in the `AddShellDocs(...)` block, plus the `RegisterComponentsFromAssembly` hint. First-time consumers no longer have to grep `ShellDocs.Components/Layouts/DocsLayout.razor` to discover the sidebar-variant option exists. [SHELLDOCS_FIXES.md #3]

### Not fixed this batch

Three items from SHELLDOCS_FIXES.md deferred; they need spec-level work rather than a patch:

- **#1 / #2 — `shelldocs build` produces no `index.html`, and `init`/`build` render-mode mismatch.** The scaffolded project is Server-interactive but `build` prints "publish kind: static (Blazor WASM)" and copies the resulting `wwwroot/` — which for a Server-interactive project has no `index.html`, no `_framework/dotnet.js`, no runtime blob. Output is unusable as a static site (blocks GH Pages / Cloudflare / Netlify deploys). Two viable paths (server-side prerender walk of the nav graph, or scaffold WASM Standalone by default) — either is a substantial change to `BuildCommand` and/or `InitCommand`. Design work needs to happen before this lands. Consumers workaround: `dotnet run` locally, skip `shelldocs build`.
- **#5 — Markdig mangles inline HTML wrappers between component slots in `razor:preview`.** Plain `<span style="color:…">` around a registered component tag inside a preview loses its parent-child relationship after Markdig's inline pass, because SlotExtractor lifts component tags before Markdig sees them. Documented workaround: use registered wrapper components with their own attribute props instead of raw inline HTML. Framework fix would need SlotExtractor to lift-and-preserve trivial wrappers (`<span>`, `<a>`, `<button>`) around component tags.

## [0.1.3-alpha] — 2026-08-12

Dogfood-driven fixes. Surfaced while writing the per-primitive / per-command / authoring pages on shelldocs.dev (30+ new sidebar entries pushed the tree past viewport height for the first time). One user-visible bug, one visual polish, both landed against the sidebar chrome.
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.3-alpha</Version>
<Version>0.1.4-alpha</Version>
<Authors>ShellUI</Authors>
<Company>ShellUI</Company>
<Copyright>Copyright © 2026 ShellUI</Copyright>
Expand Down
6 changes: 1 addition & 5 deletions src/ShellDocs.Components/Chrome/DocsSidebar.razor.css
Original file line number Diff line number Diff line change
@@ -1,11 +1,7 @@
.docs-sidebar {
display: flex;
flex-direction: column;
/* No `height: 100%` — the parent .docs-sidebar-slot in the sidebar-variant
layout already sizes us via `flex: 1; min-height: 0`. Adding height: 100%
fights that: it makes this nav match the WHOLE slot height (header
included), which pushes the footer (theme + gh links) past the visible
area whenever the tree gets tall enough to scroll. */
flex: 1;
min-height: 0;
font-size: 0.8125rem;
padding: 0.85rem 0.75rem 0 1rem;
Expand Down
23 changes: 20 additions & 3 deletions src/ShellDocs.Components/Content/PreviewFrame.razor
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,22 @@

<div class="preview-frame @(_expanded ? "expanded" : "collapsed")">
<div class="preview-render">
<DynamicComponent Type="Preview!.ComponentType"
Parameters="BuildParameters(Preview!.Parameters, Preview!.ChildContentRaw)" />
@if (Preview!.ComponentType is not null)
{
<DynamicComponent Type="Preview!.ComponentType"
Parameters="BuildParameters(Preview!.Parameters, Preview!.ChildContentRaw)" />
}
else
{

<div class="preview-error" role="alert">
<svg class="preview-error-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
<div class="preview-error-body">
<div class="preview-error-title">razor:preview error</div>
<div class="preview-error-message">@(Preview!.Error ?? "Unknown error.")</div>
</div>
</div>
}
</div>
<div class="preview-code-wrap">
<pre class="preview-code language-razor" @ref="_codeEl"><code class="language-razor">@Preview!.Code</code></pre>
Expand Down Expand Up @@ -72,6 +86,9 @@

[Inject] private IJSRuntime JS { get; set; } = default!;

/* Only called from the render-region branch guarded by
`Preview!.ComponentType is not null`, so the ! here is a safe assertion
— never dereferenced when the tag was unknown. */
private IDictionary<string, object> BuildParameters(IReadOnlyDictionary<string, string> attrs, string? childContentRaw) =>
SlotRenderer.BuildParameters(Renderer, Preview!.ComponentType, attrs, childContentRaw);
SlotRenderer.BuildParameters(Renderer, Preview!.ComponentType!, attrs, childContentRaw);
}
44 changes: 44 additions & 0 deletions src/ShellDocs.Components/Content/PreviewFrame.razor.css
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,50 @@
transparent 1px, transparent 8px);
}

.preview-error {
display: flex;
align-items: flex-start;
gap: 0.85rem;
max-width: 44rem;
padding: 1rem 1.15rem;
background: color-mix(in oklch, var(--error, oklch(0.637 0.237 25.331)) 12%, var(--card));
border: 1px solid color-mix(in oklch, var(--error, oklch(0.637 0.237 25.331)) 35%, var(--border));
border-radius: calc(var(--radius) - 2px);
color: var(--foreground);
text-align: left;
}
.preview-error-icon {
width: 1.15rem;
height: 1.15rem;
flex-shrink: 0;
color: var(--error, oklch(0.637 0.237 25.331));
margin-top: 0.1rem;
}
.preview-error-body {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
}
.preview-error-title {
font-size: 0.8125rem;
font-weight: 600;
letter-spacing: -0.005em;
}
.preview-error-message {
font-size: 0.8125rem;
color: var(--muted-foreground);
line-height: 1.5;
word-break: break-word;
}
.preview-error-message code {
font-family: var(--font-mono);
font-size: 0.78rem;
background: color-mix(in oklch, var(--foreground) 8%, transparent);
padding: 0.05rem 0.3rem;
border-radius: 3px;
}

.preview-code-wrap {
position: relative;
border-top: 1px solid var(--border);
Expand Down
15 changes: 7 additions & 8 deletions src/ShellDocs.Components/Layouts/DocsLayout.razor.css
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@
transform: translateX(-100%);
transition: transform 260ms cubic-bezier(0.16, 1, 0.3, 1);
z-index: 45;
display: flex;
flex-direction: column;
}
.docs-sidebar-slot.open {
transform: translateX(0);
Expand Down Expand Up @@ -133,10 +135,10 @@
max-width: 1500px;
grid-template-columns: var(--sidebar-width) 1fr;
}
/* Sidebar = the floating card */
/* Sidebar = the floating card. `display: flex; flex-direction: column`
already comes from the base .docs-sidebar-slot rule — we only override
the card chrome + positioning here. */
.docs-shell-sidebar .docs-sidebar-slot {
display: flex;
flex-direction: column;
position: sticky;
top: 0.85rem;
height: calc(100vh - 1.85rem);
Expand All @@ -147,11 +149,8 @@
margin-right: 0;
overflow: hidden;
}
/* DocsSidebar's own <nav> must flex-fill so its footer (theme toggle) sticks to the bottom */
.docs-shell-sidebar .docs-sidebar-slot > nav {
flex: 1;
min-height: 0;
}
/* nav `flex: 1; min-height: 0` moved to the base .docs-sidebar rule so it
applies in every layout context, not just desktop Sidebar variant. */
/* Content = flush to page bg, no border, no shadow */
.docs-shell-sidebar .docs-main {
padding: 1.5rem 0 4rem;
Expand Down
18 changes: 18 additions & 0 deletions src/ShellDocs.Components/ShellDocsOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,24 @@ tag name are silently ignored downstream in TypeRegistry. */
public ShellDocsOptions RegisterComponentsFromAssembly<TMarker>(Func<Type, bool>? filter = null)
=> RegisterComponentsFromAssembly(typeof(TMarker).Assembly, filter);


public ShellDocsOptions RegisterComponentsFromAssembly<TMarker>(string namespacePrefix)
Comment on lines 72 to +76
{
if (string.IsNullOrEmpty(namespacePrefix))
throw new ArgumentException("namespacePrefix must be non-empty.", nameof(namespacePrefix));
return RegisterComponentsFromAssembly(typeof(TMarker).Assembly,
t => t.Namespace is not null && t.Namespace.StartsWith(namespacePrefix, StringComparison.Ordinal));
}

public ShellDocsOptions RegisterComponentsFromAssembly(Assembly assembly, string namespacePrefix)
{
if (assembly is null) throw new ArgumentNullException(nameof(assembly));
if (string.IsNullOrEmpty(namespacePrefix))
throw new ArgumentException("namespacePrefix must be non-empty.", nameof(namespacePrefix));
return RegisterComponentsFromAssembly(assembly,
t => t.Namespace is not null && t.Namespace.StartsWith(namespacePrefix, StringComparison.Ordinal));
}

public ShellDocsOptions RegisterComponentsFromAssembly(Assembly assembly, Func<Type, bool>? filter = null)
{
if (assembly is null) throw new ArgumentNullException(nameof(assembly));
Expand Down
5 changes: 3 additions & 2 deletions src/ShellDocs.Markdown/RenderedDocument.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@ public record ComponentSlot(

public record PreviewSlot(
string Id,
Type ComponentType,
Type? ComponentType,
IReadOnlyDictionary<string, string> Parameters,
string Code,
string Language,
string? ChildContentRaw = null) : Slot(Id);
string? ChildContentRaw = null,
string? Error = null) : Slot(Id);
25 changes: 16 additions & 9 deletions src/ShellDocs.Markdown/SlotExtractor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -146,17 +146,11 @@ private string ReplaceComponentTags(string text, List<Slot> slots, List<string>

var name = open.Groups["name"].Value;
var type = _registry.Resolve(name);
if (type is null)
{
warnings.Add($"razor:preview references unknown component <{name}>.");
return null;
}

var attrs = ParseAttributes(open.Groups["attrs"].Value);

// Extract inner ChildContent for non-self-closing tags. e.g.
// <CardGrid>...<Card /> <Card />...</CardGrid>
// The inner text is what ends up injected as a RenderFragment at render time.
/* Extract inner ChildContent for non-self-closing tags even in the
error case — Copy button in the error state should still hand back
the exact source the author authored. */
string? childContentRaw = null;
if (!open.Groups["self"].Success)
{
Expand All @@ -167,6 +161,19 @@ private string ReplaceComponentTags(string text, List<Slot> slots, List<string>
}
}

if (type is null)
{
/* Unknown component. Emit an error PreviewSlot so PreviewFrame can
render a visible "Unknown component <X>" panel in the browser.
The pre-fix behavior returned null here, which caused the whole
fence to render as a plain code block — silent failure that sent
authors hunting for a nonexistent component bug (dogfood log,
SHELLDOCS_FIXES.md #4). Warning still emitted for build logs. */
var msg = $"Unknown component <{name}>. Register it via `o.RegisterComponent<{name}>()` or `o.RegisterComponentsFromAssembly<TMarker>()`.";
warnings.Add($"razor:preview references unknown component <{name}>.");
return new PreviewSlot(NewSlotId(), null, attrs, code, "razor", childContentRaw, Error: msg);
}

return new PreviewSlot(NewSlotId(), type, attrs, code, "razor", childContentRaw);
}

Expand Down
11 changes: 10 additions & 1 deletion src/ShellDocs.Templates/ScaffoldTemplates.cs
Original file line number Diff line number Diff line change
Expand Up @@ -180,8 +180,17 @@ public static string ProgramAddShellDocs(string siteName, string githubRepo) =>
o.ContentRoot = System.IO.Path.Combine(builder.Environment.ContentRootPath, "content");
o.SiteName = "{{siteName}}";
o.GitHubRepo = "{{githubRepo}}";

// Layout: TopNav (default) = classic header + sidebar.
// Sidebar = floating sidebar card, no top header (shadcn / fumadocs look).
// o.LayoutVariant = DocsLayoutVariant.Sidebar;

o.AddNavLink("Docs", "/docs/introduction");
// o.RegisterComponent<MyComponent>(); // for razor:preview blocks

// Register your own components so authors can drop them into
// markdown as `<MyComponent />` or inside a razor:preview fence:
// o.RegisterComponent<MyComponent>();
// o.RegisterComponentsFromAssembly<MyMarker>();
});
""";

Expand Down
8 changes: 6 additions & 2 deletions tests/ShellDocs.Tests/MarkdownRendererTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -114,13 +114,17 @@ Some paragraph.
}

[Fact]
public void Render_UnregisteredTagInPreview_SkipsWithWarning()
public void Render_UnregisteredTagInPreview_EmitsErrorSlotAndWarning()
{

var md = "```razor:preview\n<Missing />\n```";
var renderer = new MarkdownRenderer();
var doc = renderer.Render(md);

Assert.Empty(doc.Slots);
var slot = Assert.IsType<PreviewSlot>(Assert.Single(doc.Slots));
Assert.Null(slot.ComponentType);
Assert.NotNull(slot.Error);
Assert.Contains("Missing", slot.Error);
Assert.NotEmpty(renderer.LastWarnings);
}

Expand Down
Loading