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
11 changes: 11 additions & 0 deletions examples/ShellDocs.Preview/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,17 @@
o.AddNavLink("Showcase", "/showcase");
o.AddNavLink("Blog", "/blog");

o.AddPackage("shelldocs", "ShellDocs", "The docs framework itself.", "/docs/introduction",
"M12 2 4 6v6c0 5 3.5 9.5 8 10 4.5-.5 8-5 8-10V6z");
o.AddPackage("shelldocs.markdown", "ShellDocs.Markdown", "Markdig extensions, slots, MDX.", "/docs/markdown",
"M4 4h16v16H4z M4 9h16 M9 4v16");
o.AddPackage("shelldocs.core", "ShellDocs.Core", "Navigation graph + content model.", "/docs/core",
"M12 2 2 7l10 5 10-5-10-5z M2 17l10 5 10-5 M2 12l10 5 10-5");
o.AddPackage("shelldocs.cli", "ShellDocs.CLI", "Scaffold, build, publish.", "/docs/cli",
"m8 6-6 6 6 6 M16 6l6 6-6 6");
o.AddPackage("shelldocs.components","ShellDocs.Components","Layouts, header, sidebar, TOC.", "/docs/components",
"M3 3h7v7H3z M14 3h7v7h-7z M3 14h7v7H3z M14 14h7v7h-7z");

// Callout, Card, Steps, FileTree, CodeGroup, LinkCard etc. are
// auto-registered by AddShellDocs — no extra work needed here.
});
Expand Down
101 changes: 54 additions & 47 deletions src/ShellDocs.Components/Chrome/PackageSelector.razor
Original file line number Diff line number Diff line change
@@ -1,63 +1,70 @@
@inject NavigationManager Nav
@inject ShellDocsOptions Options

<div class="pkg" @onfocusout="OnBlur">
<button type="button" class="pkg-trigger" @onclick="Toggle" aria-haspopup="listbox" aria-expanded="@_open">
<span class="pkg-mark">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<path d="@Selected.IconPath"/>
@if (Options.Packages.Count > 1)
{
<div class="pkg" @onfocusout="OnBlur">
<button type="button" class="pkg-trigger" @onclick="Toggle" aria-haspopup="listbox" aria-expanded="@_open">
<span class="pkg-mark">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<path d="@(Selected.IconPath ?? DefaultIcon)"/>
</svg>
</span>
<span class="pkg-value">@Selected.Title</span>
<svg class="pkg-chevron @(_open ? "open" : "")" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="6 9 12 15 18 9"/>
</svg>
</span>
<span class="pkg-value">@Selected.Title</span>
<svg class="pkg-chevron @(_open ? "open" : "")" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
</button>

@if (_open)
{
<div class="pkg-menu" role="listbox">
@foreach (var pkg in Packages)
{
var isSel = pkg.Id == Selected.Id;
<button type="button" class="pkg-option @(isSel ? "selected" : "")" role="option" aria-selected="@isSel" @onclick="() => Choose(pkg)">
<span class="pkg-option-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<path d="@pkg.IconPath"/>
</svg>
</span>
<span class="pkg-option-body">
<span class="pkg-option-title">@pkg.Title</span>
<span class="pkg-option-desc">@pkg.Description</span>
</span>
@if (isSel)
{
<svg class="pkg-option-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
}
</button>
}
</div>
}
</div>
@if (_open)
{
<div class="pkg-menu" role="listbox">
@foreach (var pkg in Options.Packages)
{
var isSel = pkg.Id == Selected.Id;
<button type="button" class="pkg-option @(isSel ? "selected" : "")" role="option" aria-selected="@isSel" @onclick="() => Choose(pkg)">
<span class="pkg-option-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<path d="@(pkg.IconPath ?? DefaultIcon)"/>
</svg>
</span>
<span class="pkg-option-body">
<span class="pkg-option-title">@pkg.Title</span>
<span class="pkg-option-desc">@pkg.Description</span>
</span>
@if (isSel)
{
<svg class="pkg-option-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
}
</button>
}
</div>
}
</div>
}

@code {
private bool _open;

private record Package(string Id, string Title, string Description, string IconPath, string RootUrl);
private const string DefaultIcon = "M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z M3.27 6.96 12 12.01l8.73-5.05 M12 22.08V12";

private static readonly List<Package> Packages = new()
// Longest RootUrl prefix wins — "/docs/components" beats "/docs" for "/docs/components/callout".
private DocsPackage Selected
{
new("shelldocs", "ShellDocs", "The docs framework itself.", "M12 2 4 6v6c0 5 3.5 9.5 8 10 4.5-.5 8-5 8-10V6z", "/docs/introduction"),
new("shelldocs.markdown", "ShellDocs.Markdown", "Markdig extensions, slots, MDX.", "M4 4h16v16H4z M4 9h16 M9 4v16", "/docs/markdown"),
new("shelldocs.core", "ShellDocs.Core", "Navigation graph + content model.","M12 2 2 7l10 5 10-5-10-5z M2 17l10 5 10-5 M2 12l10 5 10-5", "/docs/core"),
new("shelldocs.cli", "ShellDocs.CLI", "Scaffold, build, publish.", "m8 6-6 6 6 6 M16 6l6 6-6 6", "/docs/cli"),
new("shelldocs.components","ShellDocs.Components","Layouts, header, sidebar, TOC.","M3 3h7v7H3z M14 3h7v7h-7z M3 14h7v7H3z M14 14h7v7h-7z", "/docs/components")
};

private Package Selected => Packages.FirstOrDefault(p => Nav.Uri.Contains(p.RootUrl.Split('/', StringSplitOptions.RemoveEmptyEntries).LastOrDefault() ?? "")) ?? Packages[0];
get
{
var path = new Uri(Nav.Uri).AbsolutePath;
return Options.Packages
.Where(p => path.StartsWith(p.RootUrl, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(p => p.RootUrl.Length)
.FirstOrDefault()
?? Options.Packages[0];
}
}

private void Toggle() => _open = !_open;

private void Choose(Package pkg)
private void Choose(DocsPackage pkg)
{
_open = false;
Nav.NavigateTo(pkg.RootUrl);
Expand Down
11 changes: 11 additions & 0 deletions src/ShellDocs.Components/ShellDocsOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ public class ShellDocsOptions
public DocsLayoutVariant LayoutVariant { get; set; } = DocsLayoutVariant.TopNav;

public List<NavLink> PrimaryNav { get; } = new();
// 0 or 1 entries hides the sidebar package selector entirely.
public List<DocsPackage> Packages { get; } = new();
public List<Type> RegisteredComponents { get; } = new();
/* Per-type tag alias — when a type appears here, BuildTypeRegistry uses this
string as the markdown-facing tag name instead of the type's short name.
Expand Down Expand Up @@ -109,6 +111,12 @@ public ShellDocsOptions AddNavMenu(string label, params NavMenuItem[] items)
return this;
}

public ShellDocsOptions AddPackage(string id, string title, string description, string rootUrl, string? iconPath = null)
{
Packages.Add(new DocsPackage(id, title, description, rootUrl, iconPath));
return this;
}

internal TypeRegistry BuildTypeRegistry()
{
var registry = new TypeRegistry();
Expand All @@ -126,6 +134,9 @@ internal TypeRegistry BuildTypeRegistry()
public record NavLink(string Label, string Href, List<NavMenuItem>? Children = null);
public record NavMenuItem(string Label, string Href, string? Description = null, string? IconSvg = null);

// IconPath is a raw SVG `d` attribute value on a 24×24 viewBox — not a URL.
public record DocsPackage(string Id, string Title, string Description, string RootUrl, string? IconPath = null);

public enum ShellDocsTheme
{
Shadcn,
Expand Down
30 changes: 30 additions & 0 deletions tests/ShellDocs.Tests/PackageRegistrationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using ShellDocs.Components;
using Xunit;

namespace ShellDocs.Tests;

public class PackageRegistrationTests
{
[Fact]
public void AddPackage_FluentChain_AppendsInOrder()
{
var options = new ShellDocsOptions()
.AddPackage("core", "Core", "Foundation.", "/docs/core")
.AddPackage("components", "Components", "Widgets.", "/docs/components", iconPath: "M0 0h10v10H0z");

Assert.Equal(2, options.Packages.Count);
Assert.Equal("core", options.Packages[0].Id);
Assert.Equal("Core", options.Packages[0].Title);
Assert.Equal("/docs/core", options.Packages[0].RootUrl);
Assert.Null(options.Packages[0].IconPath);
Assert.Equal("components", options.Packages[1].Id);
Assert.Equal("M0 0h10v10H0z", options.Packages[1].IconPath);
}

[Fact]
public void Packages_DefaultToEmpty_SoSelectorHides()
{
var options = new ShellDocsOptions();
Assert.Empty(options.Packages);
}
}
Loading