From 9bd4047f25a48cb1c35e9370f694c3d3f13a0370 Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Tue, 21 Jul 2026 23:29:54 +0200 Subject: [PATCH 1/7] feat: add new UI components including Callout, Card, CardGrid, CodeGroup, and FileTree with corresponding styles, enhancing content presentation and organization --- .../Content/Callout.razor | 40 ++++++++++ .../Content/Callout.razor.css | 27 +++++++ src/ShellDocs.Components/Content/Card.razor | 63 +++++++++++++++ .../Content/Card.razor.css | 35 +++++++++ .../Content/CardGrid.razor | 10 +++ .../Content/CardGrid.razor.css | 13 ++++ .../Content/CodeGroup.razor | 78 +++++++++++++++++++ .../Content/CodeGroup.razor.css | 61 +++++++++++++++ .../Content/CodeGroupSyncState.cs | 20 +++++ .../Content/CodeTab.razor | 15 ++++ .../Content/FileTree.razor | 11 +++ .../Content/FileTree.razor.css | 50 ++++++++++++ .../Content/FileTreeItem.razor | 33 ++++++++ .../Content/LinkCard.razor | 24 ++++++ .../Content/LinkCard.razor.css | 32 ++++++++ src/ShellDocs.Components/Content/Step.razor | 16 ++++ src/ShellDocs.Components/Content/Steps.razor | 9 +++ .../Content/Steps.razor.css | 47 +++++++++++ 18 files changed, 584 insertions(+) create mode 100644 src/ShellDocs.Components/Content/Callout.razor create mode 100644 src/ShellDocs.Components/Content/Callout.razor.css create mode 100644 src/ShellDocs.Components/Content/Card.razor create mode 100644 src/ShellDocs.Components/Content/Card.razor.css create mode 100644 src/ShellDocs.Components/Content/CardGrid.razor create mode 100644 src/ShellDocs.Components/Content/CardGrid.razor.css create mode 100644 src/ShellDocs.Components/Content/CodeGroup.razor create mode 100644 src/ShellDocs.Components/Content/CodeGroup.razor.css create mode 100644 src/ShellDocs.Components/Content/CodeGroupSyncState.cs create mode 100644 src/ShellDocs.Components/Content/CodeTab.razor create mode 100644 src/ShellDocs.Components/Content/FileTree.razor create mode 100644 src/ShellDocs.Components/Content/FileTree.razor.css create mode 100644 src/ShellDocs.Components/Content/FileTreeItem.razor create mode 100644 src/ShellDocs.Components/Content/LinkCard.razor create mode 100644 src/ShellDocs.Components/Content/LinkCard.razor.css create mode 100644 src/ShellDocs.Components/Content/Step.razor create mode 100644 src/ShellDocs.Components/Content/Steps.razor create mode 100644 src/ShellDocs.Components/Content/Steps.razor.css diff --git a/src/ShellDocs.Components/Content/Callout.razor b/src/ShellDocs.Components/Content/Callout.razor new file mode 100644 index 0000000..ba3daab --- /dev/null +++ b/src/ShellDocs.Components/Content/Callout.razor @@ -0,0 +1,40 @@ +@namespace ShellDocs.Components.Content + +
+ +
+ @if (!string.IsNullOrEmpty(Title)) + { +
@Title
+ } +
+ @if (ChildContent is not null) { @ChildContent } + else if (!string.IsNullOrEmpty(Text)) { @Text } +
+
+
+ +@code { + [Parameter] public string Variant { get; set; } = "info"; + [Parameter] public string? Title { get; set; } + [Parameter] public string? Text { get; set; } + [Parameter] public RenderFragment? ChildContent { get; set; } +} diff --git a/src/ShellDocs.Components/Content/Callout.razor.css b/src/ShellDocs.Components/Content/Callout.razor.css new file mode 100644 index 0000000..2bd698b --- /dev/null +++ b/src/ShellDocs.Components/Content/Callout.razor.css @@ -0,0 +1,27 @@ +.callout { + display: flex; + gap: 0.85rem; + padding: 0.9rem 1.1rem; + border: 1px solid var(--border); + border-radius: calc(var(--radius) + 2px); + background: color-mix(in oklch, var(--card) 70%, var(--background)); + line-height: 1.6; + font-size: 0.9rem; + margin: 1.25rem 0; +} +.callout-icon { flex-shrink: 0; display: inline-flex; align-items: flex-start; padding-top: 0.05rem; } +.callout-icon svg { width: 1.05rem; height: 1.05rem; } +.callout-body { flex: 1; min-width: 0; } +.callout-title { font-weight: 600; margin-bottom: 0.2rem; letter-spacing: -0.005em; color: var(--foreground); } +.callout-content { color: var(--muted-foreground); } +.callout-content > *:first-child { margin-top: 0; } +.callout-content > *:last-child { margin-bottom: 0; } + +.callout-info { border-color: color-mix(in oklch, var(--info) 30%, var(--border)); } +.callout-info .callout-icon { color: var(--info); } +.callout-warning { border-color: color-mix(in oklch, var(--warning) 35%, var(--border)); } +.callout-warning .callout-icon { color: var(--warning); } +.callout-danger, .callout-error { border-color: color-mix(in oklch, var(--error) 35%, var(--border)); } +.callout-danger .callout-icon, .callout-error .callout-icon { color: var(--error); } +.callout-tip, .callout-success { border-color: color-mix(in oklch, var(--success) 30%, var(--border)); } +.callout-tip .callout-icon, .callout-success .callout-icon { color: var(--success); } diff --git a/src/ShellDocs.Components/Content/Card.razor b/src/ShellDocs.Components/Content/Card.razor new file mode 100644 index 0000000..2f5bde6 --- /dev/null +++ b/src/ShellDocs.Components/Content/Card.razor @@ -0,0 +1,63 @@ +@namespace ShellDocs.Components.Content + +@{ + var isLink = !string.IsNullOrEmpty(Href); + var tag = isLink ? "a" : "div"; +} + +@if (isLink) +{ + + @Body + +} +else +{ +
+ @Body +
+} + +@code { + [Parameter] public string? Title { get; set; } + [Parameter] public string? Description { get; set; } + [Parameter] public string? Href { get; set; } + [Parameter] public string? IconSvg { get; set; } + [Parameter] public bool External { get; set; } + [Parameter] public RenderFragment? ChildContent { get; set; } + + private RenderFragment Body => __b => + { + if (!string.IsNullOrEmpty(IconSvg)) + { + __b.OpenElement(0, "span"); + __b.AddAttribute(1, "class", "doc-card-icon"); + __b.AddMarkupContent(2, IconSvg); + __b.CloseElement(); + } + __b.OpenElement(3, "div"); + __b.AddAttribute(4, "class", "doc-card-body"); + if (!string.IsNullOrEmpty(Title)) + { + __b.OpenElement(5, "div"); + __b.AddAttribute(6, "class", "doc-card-title"); + __b.AddContent(7, Title); + __b.CloseElement(); + } + if (!string.IsNullOrEmpty(Description)) + { + __b.OpenElement(8, "div"); + __b.AddAttribute(9, "class", "doc-card-description"); + __b.AddContent(10, Description); + __b.CloseElement(); + } + if (ChildContent is not null) + { + __b.OpenElement(11, "div"); + __b.AddAttribute(12, "class", "doc-card-content"); + __b.AddContent(13, ChildContent); + __b.CloseElement(); + } + __b.CloseElement(); + }; +} diff --git a/src/ShellDocs.Components/Content/Card.razor.css b/src/ShellDocs.Components/Content/Card.razor.css new file mode 100644 index 0000000..dc48d61 --- /dev/null +++ b/src/ShellDocs.Components/Content/Card.razor.css @@ -0,0 +1,35 @@ +.doc-card { + display: flex; + gap: 0.75rem; + padding: 1rem 1.15rem; + border: 1px solid var(--border); + border-radius: calc(var(--radius) + 2px); + background: var(--card); + color: var(--foreground); + text-decoration: none; + transition: border-color 200ms, background 200ms, transform 200ms; + align-items: flex-start; +} +a.doc-card:hover { + border-color: var(--border-strong); + background: var(--muted); +} + +::deep .doc-card-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border-radius: 6px; + background: var(--muted); + color: var(--foreground); + flex-shrink: 0; +} +::deep .doc-card-icon svg { width: 1rem; height: 1rem; } +::deep .doc-card-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 0.2rem; } +::deep .doc-card-title { font-weight: 600; font-size: 0.9375rem; letter-spacing: -0.005em; color: var(--foreground); } +::deep .doc-card-description { color: var(--muted-foreground); font-size: 0.85rem; line-height: 1.5; } +::deep .doc-card-content { color: var(--muted-foreground); font-size: 0.85rem; line-height: 1.5; margin-top: 0.25rem; } +::deep .doc-card-content > *:first-child { margin-top: 0; } +::deep .doc-card-content > *:last-child { margin-bottom: 0; } diff --git a/src/ShellDocs.Components/Content/CardGrid.razor b/src/ShellDocs.Components/Content/CardGrid.razor new file mode 100644 index 0000000..ac3f5a4 --- /dev/null +++ b/src/ShellDocs.Components/Content/CardGrid.razor @@ -0,0 +1,10 @@ +@namespace ShellDocs.Components.Content + +
+ @ChildContent +
+ +@code { + [Parameter] public int Columns { get; set; } = 2; + [Parameter] public RenderFragment? ChildContent { get; set; } +} diff --git a/src/ShellDocs.Components/Content/CardGrid.razor.css b/src/ShellDocs.Components/Content/CardGrid.razor.css new file mode 100644 index 0000000..ff6175a --- /dev/null +++ b/src/ShellDocs.Components/Content/CardGrid.razor.css @@ -0,0 +1,13 @@ +.card-grid { + display: grid; + grid-template-columns: 1fr; + gap: 0.85rem; + margin: 1.5rem 0; +} +@media (min-width: 640px) { + .card-grid[data-cols="2"] { grid-template-columns: repeat(2, 1fr); } + .card-grid[data-cols="3"] { grid-template-columns: repeat(2, 1fr); } +} +@media (min-width: 900px) { + .card-grid[data-cols="3"] { grid-template-columns: repeat(3, 1fr); } +} diff --git a/src/ShellDocs.Components/Content/CodeGroup.razor b/src/ShellDocs.Components/Content/CodeGroup.razor new file mode 100644 index 0000000..538d048 --- /dev/null +++ b/src/ShellDocs.Components/Content/CodeGroup.razor @@ -0,0 +1,78 @@ +@namespace ShellDocs.Components.Content +@inject CodeGroupSyncState Sync +@implements IDisposable + +
+
+ @foreach (var tab in _tabs) + { + var isActive = tab.Label == _active; + + } +
+
+ @foreach (var tab in _tabs) + { + var isActive = tab.Label == _active; + + } +
+
+ + + @* Consume ChildContent silently — CodeTab siblings register themselves via + this cascading value; the CodeTabs themselves don't render anything from + their own body — we replay their Content above. *@ +
@ChildContent
+
+ +@code { + [Parameter] public string? SyncKey { get; set; } + [Parameter] public RenderFragment? ChildContent { get; set; } + + private readonly List<(string Label, RenderFragment Content)> _tabs = new(); + private string? _active; + + internal void Register(string label, RenderFragment content) + { + if (_tabs.Any(t => t.Label == label)) return; + _tabs.Add((label, content)); + // Initial selection: sync-group's stored pick if available, else first tab. + if (_active is null) + { + _active = (SyncKey is not null ? Sync.Get(SyncKey) : null) ?? label; + StateHasChanged(); + } + } + + private void Select(string label) + { + if (_active == label) return; + _active = label; + if (SyncKey is not null) Sync.Set(SyncKey, label); + StateHasChanged(); + } + + protected override void OnInitialized() => Sync.OnChange += OnSyncChange; + + private void OnSyncChange(string changedKey) + { + if (SyncKey != changedKey) return; + var v = Sync.Get(SyncKey); + if (v is not null && _tabs.Any(t => t.Label == v) && _active != v) + { + _active = v; + InvokeAsync(StateHasChanged); + } + } + + public void Dispose() => Sync.OnChange -= OnSyncChange; +} diff --git a/src/ShellDocs.Components/Content/CodeGroup.razor.css b/src/ShellDocs.Components/Content/CodeGroup.razor.css new file mode 100644 index 0000000..bfbad91 --- /dev/null +++ b/src/ShellDocs.Components/Content/CodeGroup.razor.css @@ -0,0 +1,61 @@ +.code-group { + border: 1px solid var(--border); + border-radius: calc(var(--radius) + 2px); + overflow: hidden; + margin: 1.5rem 0; + background: var(--card); +} + +.code-group-tabs { + display: flex; + align-items: center; + gap: 0.1rem; + padding: 0.35rem 0.4rem; + background: color-mix(in oklch, var(--card) 55%, var(--background)); + border-bottom: 1px solid var(--border); + overflow-x: auto; + scrollbar-width: thin; +} +.code-group-tab { + padding: 0.35rem 0.7rem; + background: transparent; + border: 0; + border-radius: calc(var(--radius) - 3px); + color: var(--muted-foreground); + font-family: var(--font-mono); + font-size: 0.78rem; + font-weight: 500; + cursor: pointer; + transition: color 150ms, background 150ms; + white-space: nowrap; +} +.code-group-tab:hover { color: var(--foreground); } +.code-group-tab.active { + color: var(--foreground); + background: var(--muted); +} + +.code-group-panel { position: relative; } + +::deep .code-group-content { + padding: 1rem 1.25rem; + font-family: var(--font-mono); + font-size: 0.8125rem; + line-height: 1.65; + color: var(--foreground); + overflow-x: auto; +} + +/* When the tab body is a real fenced code block, the inner
 already has
+   its own padding — strip our wrapper padding so we don't stack. */
+::deep .code-group-content:has(> pre),
+::deep .code-group-content:has(> .shelldocs-codeblock) {
+    padding: 0;
+}
+::deep .code-group-content > pre,
+::deep .code-group-content > .shelldocs-codeblock,
+::deep .code-group-content > pre.shiki {
+    margin: 0 !important;
+    border-radius: 0 !important;
+    border: 0 !important;
+}
diff --git a/src/ShellDocs.Components/Content/CodeGroupSyncState.cs b/src/ShellDocs.Components/Content/CodeGroupSyncState.cs
new file mode 100644
index 0000000..dde845e
--- /dev/null
+++ b/src/ShellDocs.Components/Content/CodeGroupSyncState.cs
@@ -0,0 +1,20 @@
+namespace ShellDocs.Components.Content;
+
+/* Cross-page sync for . When the reader
+   picks "pnpm" on one CodeGroup, every other CodeGroup on the page with the
+   same SyncKey jumps to "pnpm" too. Scoped per circuit so the choice sticks
+   within a session; localStorage persistence is a follow-up. */
+public class CodeGroupSyncState
+{
+    private readonly Dictionary _selected = new(StringComparer.Ordinal);
+    public event Action? OnChange;
+
+    public string? Get(string key) => _selected.TryGetValue(key, out var v) ? v : null;
+
+    public void Set(string key, string value)
+    {
+        if (_selected.TryGetValue(key, out var current) && current == value) return;
+        _selected[key] = value;
+        OnChange?.Invoke(key);
+    }
+}
diff --git a/src/ShellDocs.Components/Content/CodeTab.razor b/src/ShellDocs.Components/Content/CodeTab.razor
new file mode 100644
index 0000000..f21d576
--- /dev/null
+++ b/src/ShellDocs.Components/Content/CodeTab.razor
@@ -0,0 +1,15 @@
+@namespace ShellDocs.Components.Content
+
+@code {
+    [Parameter, EditorRequired] public string Label { get; set; } = "";
+    [Parameter] public RenderFragment? ChildContent { get; set; }
+    [CascadingParameter] public CodeGroup? Parent { get; set; }
+
+    protected override void OnInitialized()
+    {
+        if (Parent is not null && ChildContent is not null)
+        {
+            Parent.Register(Label, ChildContent);
+        }
+    }
+}
diff --git a/src/ShellDocs.Components/Content/FileTree.razor b/src/ShellDocs.Components/Content/FileTree.razor
new file mode 100644
index 0000000..34cd540
--- /dev/null
+++ b/src/ShellDocs.Components/Content/FileTree.razor
@@ -0,0 +1,11 @@
+@namespace ShellDocs.Components.Content
+
+
+
    + @ChildContent +
+
+ +@code { + [Parameter] public RenderFragment? ChildContent { get; set; } +} diff --git a/src/ShellDocs.Components/Content/FileTree.razor.css b/src/ShellDocs.Components/Content/FileTree.razor.css new file mode 100644 index 0000000..b67ad35 --- /dev/null +++ b/src/ShellDocs.Components/Content/FileTree.razor.css @@ -0,0 +1,50 @@ +.filetree { + padding: 0.9rem 1rem; + border: 1px solid var(--border); + border-radius: calc(var(--radius) + 2px); + background: var(--card); + margin: 1.5rem 0; + font-family: var(--font-mono); + font-size: 0.8125rem; + line-height: 1.6; +} +.filetree-list { + list-style: none; + padding-left: 0; + margin: 0; +} + +::deep .filetree-item { + position: relative; + padding: 0.15rem 0; + color: var(--foreground); +} +::deep .filetree-item .filetree-list { + padding-left: 1.15rem; + margin: 0.15rem 0 0.15rem 0.4rem; + border-left: 1px solid var(--border); +} + +::deep .filetree-glyph { + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--muted-foreground); + margin-right: 0.4rem; + vertical-align: middle; +} +::deep .filetree-glyph svg { width: 0.85rem; height: 0.85rem; } +::deep .filetree-item.is-folder > .filetree-glyph { color: var(--foreground); } +::deep .filetree-item.highlight > .filetree-label { + background: color-mix(in oklch, var(--warning) 20%, transparent); + padding: 0 0.35rem; + border-radius: 3px; +} + +::deep .filetree-label { color: inherit; } +::deep .filetree-comment { + color: var(--muted-foreground); + margin-left: 0.6rem; + font-style: italic; + font-size: 0.9em; +} diff --git a/src/ShellDocs.Components/Content/FileTreeItem.razor b/src/ShellDocs.Components/Content/FileTreeItem.razor new file mode 100644 index 0000000..9ed1cb0 --- /dev/null +++ b/src/ShellDocs.Components/Content/FileTreeItem.razor @@ -0,0 +1,33 @@ +@namespace ShellDocs.Components.Content + +
  • + + @Name + @if (!string.IsNullOrEmpty(Comment)) + { + @Comment + } + @if (IsFolder && ChildContent is not null) + { +
      + @ChildContent +
    + } +
  • + +@code { + [Parameter, EditorRequired] public string Name { get; set; } = ""; + [Parameter] public bool IsFolder { get; set; } + [Parameter] public string? Comment { get; set; } + [Parameter] public bool Highlight { get; set; } + [Parameter] public RenderFragment? ChildContent { get; set; } +} diff --git a/src/ShellDocs.Components/Content/LinkCard.razor b/src/ShellDocs.Components/Content/LinkCard.razor new file mode 100644 index 0000000..866d018 --- /dev/null +++ b/src/ShellDocs.Components/Content/LinkCard.razor @@ -0,0 +1,24 @@ +@namespace ShellDocs.Components.Content + + + + + + + + +@code { + [Parameter] public string? Title { get; set; } + [Parameter] public string? Description { get; set; } + [Parameter, EditorRequired] public string Href { get; set; } = "#"; + [Parameter] public bool External { get; set; } +} diff --git a/src/ShellDocs.Components/Content/LinkCard.razor.css b/src/ShellDocs.Components/Content/LinkCard.razor.css new file mode 100644 index 0000000..cb5ef72 --- /dev/null +++ b/src/ShellDocs.Components/Content/LinkCard.razor.css @@ -0,0 +1,32 @@ +.link-card { + display: flex; + align-items: center; + gap: 0.85rem; + padding: 0.9rem 1.15rem; + border: 1px solid var(--border); + border-radius: calc(var(--radius) + 2px); + background: var(--card); + color: var(--foreground); + text-decoration: none; + transition: border-color 200ms, background 200ms; + margin: 0.75rem 0; +} +.link-card:hover { + border-color: var(--border-strong); + background: var(--muted); +} +.link-card:hover .link-card-arrow { color: var(--foreground); transform: translateX(2px); } + +.link-card-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 0.15rem; } +.link-card-title { font-weight: 600; font-size: 0.9375rem; letter-spacing: -0.005em; } +.link-card-desc { color: var(--muted-foreground); font-size: 0.85rem; line-height: 1.5; } + +.link-card-arrow { + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--muted-foreground); + transition: color 200ms, transform 200ms cubic-bezier(0.16, 1, 0.3, 1); + flex-shrink: 0; +} +.link-card-arrow svg { width: 1rem; height: 1rem; } diff --git a/src/ShellDocs.Components/Content/Step.razor b/src/ShellDocs.Components/Content/Step.razor new file mode 100644 index 0000000..c1e82df --- /dev/null +++ b/src/ShellDocs.Components/Content/Step.razor @@ -0,0 +1,16 @@ +@namespace ShellDocs.Components.Content + +
  • + @if (!string.IsNullOrEmpty(Title)) + { +
    @Title
    + } +
    + @ChildContent +
    +
  • + +@code { + [Parameter] public string? Title { get; set; } + [Parameter] public RenderFragment? ChildContent { get; set; } +} diff --git a/src/ShellDocs.Components/Content/Steps.razor b/src/ShellDocs.Components/Content/Steps.razor new file mode 100644 index 0000000..39a474f --- /dev/null +++ b/src/ShellDocs.Components/Content/Steps.razor @@ -0,0 +1,9 @@ +@namespace ShellDocs.Components.Content + +
      + @ChildContent +
    + +@code { + [Parameter] public RenderFragment? ChildContent { get; set; } +} diff --git a/src/ShellDocs.Components/Content/Steps.razor.css b/src/ShellDocs.Components/Content/Steps.razor.css new file mode 100644 index 0000000..d596aae --- /dev/null +++ b/src/ShellDocs.Components/Content/Steps.razor.css @@ -0,0 +1,47 @@ +.steps { + list-style: none; + counter-reset: step; + padding: 0; + margin: 1.5rem 0; + border-left: 1px solid var(--border); +} + +::deep .step { + counter-increment: step; + position: relative; + padding: 0 0 1.5rem 2.5rem; + margin-left: 0.9rem; + color: var(--foreground); +} +::deep .step:last-child { padding-bottom: 0; } + +::deep .step::before { + content: counter(step); + position: absolute; + left: -1.05rem; + top: 0; + width: 1.85rem; + height: 1.85rem; + background: var(--muted); + color: var(--foreground); + border: 1px solid var(--border); + border-radius: 9999px; + display: inline-flex; + align-items: center; + justify-content: center; + font-family: var(--font-mono); + font-size: 0.75rem; + font-weight: 600; + letter-spacing: -0.02em; +} + +::deep .step-title { + margin: 0 0 0.35rem; + font-size: 1.05rem; + font-weight: 600; + letter-spacing: -0.015em; + line-height: 1.4; +} +::deep .step-content { color: var(--muted-foreground); font-size: 0.9rem; line-height: 1.6; } +::deep .step-content > *:first-child { margin-top: 0; } +::deep .step-content > *:last-child { margin-bottom: 0; } From d46db7fa815731185713302565b1c831e0bdfaf8 Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Tue, 21 Jul 2026 23:31:36 +0200 Subject: [PATCH 2/7] feat: introduce new components including Card, CodeGroup, FileTree, and Steps, enhancing documentation structure and user navigation --- .../content/docs/components/card.md | 45 +++++++++++++++++++ .../content/docs/components/code-group.md | 38 ++++++++++++++++ .../content/docs/components/filetree.md | 42 +++++++++++++++++ .../content/docs/components/meta.json | 2 +- .../content/docs/components/steps.md | 35 +++++++++++++++ 5 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 examples/ShellDocs.Preview/content/docs/components/card.md create mode 100644 examples/ShellDocs.Preview/content/docs/components/code-group.md create mode 100644 examples/ShellDocs.Preview/content/docs/components/filetree.md create mode 100644 examples/ShellDocs.Preview/content/docs/components/steps.md diff --git a/examples/ShellDocs.Preview/content/docs/components/card.md b/examples/ShellDocs.Preview/content/docs/components/card.md new file mode 100644 index 0000000..c2c7582 --- /dev/null +++ b/examples/ShellDocs.Preview/content/docs/components/card.md @@ -0,0 +1,45 @@ +--- +title: Card +description: Bordered cards with title, description, optional icon, and optional link. +category: Components +order: 12 +--- + +# Card + +`` renders a bordered rounded panel with a title, description, and optional icon. Pass `Href` to make the whole card a link. + +## Basic + +```razor:preview + +``` + +## As a link + +Add `Href` and the card becomes a link with a hover accent. + +```razor:preview + +``` + +## In a grid + +Wrap Cards in `` for a responsive 2-col (or 3-col) layout. + +```razor:preview + + + + + + +``` + +## LinkCard + +For "further reading" panels, `` is a compact one-line variant with a hover arrow. + +```razor:preview + +``` diff --git a/examples/ShellDocs.Preview/content/docs/components/code-group.md b/examples/ShellDocs.Preview/content/docs/components/code-group.md new file mode 100644 index 0000000..66db2f1 --- /dev/null +++ b/examples/ShellDocs.Preview/content/docs/components/code-group.md @@ -0,0 +1,38 @@ +--- +title: CodeGroup +description: Tabbed code samples that sync across the page. +category: Components +order: 25 +--- + +# CodeGroup + +`` groups multiple code samples into a tabbed panel — the reader picks one, sees that source, ignores the others. The most common use is a per-package-manager install snippet. + +## Basic + +```razor:preview + + npm install shelldocs + pnpm add shelldocs + yarn add shelldocs + +``` + +## Sync groups + +Pass `SyncKey` and every `` on the page with the same key switches together. Pick "pnpm" here — every other snippet with `SyncKey="pkg"` on the page will also read pnpm. + +```razor:preview + + npm run build + pnpm build + yarn build + +``` + +## Notes + +- The first `` in source order is the default selection on first render (unless overridden by an active sync group). +- Choose stable sync keys — the sync state uses the key as its dictionary bucket, so renaming a key resets everyone's pick. +- Sync is per-circuit today. Cross-session persistence via `localStorage` lands in a follow-up. diff --git a/examples/ShellDocs.Preview/content/docs/components/filetree.md b/examples/ShellDocs.Preview/content/docs/components/filetree.md new file mode 100644 index 0000000..a1c1904 --- /dev/null +++ b/examples/ShellDocs.Preview/content/docs/components/filetree.md @@ -0,0 +1,42 @@ +--- +title: FileTree +description: Static folder / file visualisation for project layout diagrams. +category: Components +order: 50 +--- + +# FileTree + +`` renders a static folder-and-file tree — the kind of "here's what your project structure looks like" diagram you see in every framework's getting-started guide. + +## Basic + +```razor:preview + + + + + + + + + + + + + + + + + + + + +``` + +## Props + +- `Name` — the file or folder name shown next to the glyph +- `IsFolder` — draws the folder glyph and enables nested children +- `Comment` — muted italic comment shown to the right (e.g. `// sidebar order`) +- `Highlight` — soft warning-coloured background on the label to draw attention to a specific line diff --git a/examples/ShellDocs.Preview/content/docs/components/meta.json b/examples/ShellDocs.Preview/content/docs/components/meta.json index 29b790b..5b936ee 100644 --- a/examples/ShellDocs.Preview/content/docs/components/meta.json +++ b/examples/ShellDocs.Preview/content/docs/components/meta.json @@ -1,4 +1,4 @@ { "title": "Components", - "pages": ["callout", "code-block", "tabs"] + "pages": ["callout", "card", "code-block", "code-group", "steps", "tabs", "filetree"] } diff --git a/examples/ShellDocs.Preview/content/docs/components/steps.md b/examples/ShellDocs.Preview/content/docs/components/steps.md new file mode 100644 index 0000000..7f26044 --- /dev/null +++ b/examples/ShellDocs.Preview/content/docs/components/steps.md @@ -0,0 +1,35 @@ +--- +title: Steps +description: Vertically-numbered onboarding sequence with a connecting rail. +category: Components +order: 40 +--- + +# Steps + +Use `` to lay out an onboarding sequence — install, configure, run. Each `` is auto-numbered by its position; you don't manage the counter. + +## Basic + +```razor:preview + + + Install the ShellDocs global tool from NuGet. + + + Run shelldocs init in your repo root. + + + Edit content/docs/introduction.md. It hot-reloads. + + + shelldocs build emits a static site. Deploy anywhere. + + +``` + +## Notes + +- Each Step's `Title` is optional — omit for a rendered numbered paragraph. +- Nest richer content (paragraphs, code, callouts) via `ChildContent`. +- The rail is drawn from the outer `` `border-left`; step number chips overlay it. From 7a9b6295838f7ebce750a351169931e9cf7509a5 Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Tue, 21 Jul 2026 23:32:05 +0200 Subject: [PATCH 3/7] refactor: remove demo Callout component and update registration comment for auto-registered components in ShellDocs --- .../Components/Demos/Callout.razor | 68 ------------------- examples/ShellDocs.Preview/Program.cs | 4 +- 2 files changed, 2 insertions(+), 70 deletions(-) delete mode 100644 examples/ShellDocs.Preview/Components/Demos/Callout.razor diff --git a/examples/ShellDocs.Preview/Components/Demos/Callout.razor b/examples/ShellDocs.Preview/Components/Demos/Callout.razor deleted file mode 100644 index d022fc5..0000000 --- a/examples/ShellDocs.Preview/Components/Demos/Callout.razor +++ /dev/null @@ -1,68 +0,0 @@ -@* Demo Callout — a temporary implementation local to the preview app. - The production ships in feat/content-primitives (Phase 2). *@ - -
    - -
    - @if (!string.IsNullOrEmpty(Title)) - { -
    @Title
    - } -
    - @if (ChildContent is not null) { @ChildContent } - else { @Text } -
    -
    -
    - -@code { - [Parameter] public string Variant { get; set; } = "info"; - [Parameter] public string? Title { get; set; } - [Parameter] public string? Text { get; set; } - [Parameter] public RenderFragment? ChildContent { get; set; } -} - - diff --git a/examples/ShellDocs.Preview/Program.cs b/examples/ShellDocs.Preview/Program.cs index 203ba0c..317e4dc 100644 --- a/examples/ShellDocs.Preview/Program.cs +++ b/examples/ShellDocs.Preview/Program.cs @@ -33,8 +33,8 @@ o.AddNavLink("Showcase", "/showcase"); o.AddNavLink("Blog", "/blog"); - // Demo components available for blocks in markdown. - o.RegisterComponent(); + // Callout, Card, Steps, FileTree, CodeGroup, LinkCard etc. are + // auto-registered by AddShellDocs — no extra work needed here. }); var app = builder.Build(); From 28b0d46340ece0c8b0c7768cca2059e93736559b Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Tue, 21 Jul 2026 23:33:12 +0200 Subject: [PATCH 4/7] refactor: streamline PreviewFrame and MarkdownContent components by integrating SlotRenderer for parameter handling and removing unused code, enhancing maintainability and performance --- .../Content/MarkdownContent.razor | 14 +- .../Content/PreviewFrame.razor | 80 +++++------ .../Content/PreviewFrame.razor.css | 136 ++++++++++-------- .../Content/SlotRenderer.cs | 119 +++++++++++++++ 4 files changed, 234 insertions(+), 115 deletions(-) create mode 100644 src/ShellDocs.Components/Content/SlotRenderer.cs diff --git a/src/ShellDocs.Components/Content/MarkdownContent.razor b/src/ShellDocs.Components/Content/MarkdownContent.razor index 31d29d2..4386318 100644 --- a/src/ShellDocs.Components/Content/MarkdownContent.razor +++ b/src/ShellDocs.Components/Content/MarkdownContent.razor @@ -48,17 +48,5 @@ } private IDictionary BuildParameters(ComponentSlot slot) => - BuildParameters(slot.Parameters, slot.ChildContentRaw); - - private IDictionary BuildParameters(IReadOnlyDictionary attrs, string? childContentRaw) - { - var dict = new Dictionary(StringComparer.Ordinal); - foreach (var (k, v) in attrs) dict[k] = v; - if (!string.IsNullOrEmpty(childContentRaw)) - { - dict["ChildContent"] = (RenderFragment)(builder => - builder.AddMarkupContent(0, childContentRaw)); - } - return dict; - } + SlotRenderer.BuildParameters(Renderer, slot.ComponentType, slot.Parameters, slot.ChildContentRaw); } diff --git a/src/ShellDocs.Components/Content/PreviewFrame.razor b/src/ShellDocs.Components/Content/PreviewFrame.razor index 41d14cd..85b8dfe 100644 --- a/src/ShellDocs.Components/Content/PreviewFrame.razor +++ b/src/ShellDocs.Components/Content/PreviewFrame.razor @@ -1,44 +1,34 @@ @using ShellDocs.Markdown +@inject MarkdownRenderer Renderer -
    -
    - - -
    - @if (_showCode) - { - - } +
    +
    +
    -
    - @if (!_showCode) +
    +
    @Preview!.Code
    + @if (!_expanded) { -
    - +
    +
    } else { - @* Shiki ships a razor grammar — full razor highlighting for previews. *@ -
    @Preview!.Code
    +
    + + +
    }
    @@ -46,17 +36,21 @@ @code { [Parameter, EditorRequired] public PreviewSlot Preview { get; set; } = default!; - private bool _showCode; + private bool _expanded; private bool _copied; + private bool _highlighted; private ElementReference _codeEl; + private void Expand() => _expanded = true; + private void Collapse() => _expanded = false; + protected override async Task OnAfterRenderAsync(bool firstRender) { - /* Prism's global highlightAll runs once when MarkdownContent mounts, but - the code tab renders lazily — re-highlight this specific block whenever - the code tab becomes visible. */ - if (_showCode) + /* Highlight once; the highlighted DOM stays put whether collapsed or + expanded (max-height clips it visually, no diff churn). */ + if (firstRender && !_highlighted) { + _highlighted = true; try { await JS.InvokeVoidAsync("shelldocsHighlightElement", _codeEl); } catch { } } } @@ -67,18 +61,16 @@ { await JS.InvokeVoidAsync("navigator.clipboard.writeText", Preview.Code); _copied = true; + StateHasChanged(); await Task.Delay(1400); _copied = false; + StateHasChanged(); } catch { } } [Inject] private IJSRuntime JS { get; set; } = default!; - private IDictionary BuildParameters(IReadOnlyDictionary attrs) - { - var dict = new Dictionary(StringComparer.Ordinal); - foreach (var (k, v) in attrs) dict[k] = v; - return dict; - } + private IDictionary BuildParameters(IReadOnlyDictionary attrs, string? childContentRaw) => + SlotRenderer.BuildParameters(Renderer, Preview!.ComponentType, attrs, childContentRaw); } diff --git a/src/ShellDocs.Components/Content/PreviewFrame.razor.css b/src/ShellDocs.Components/Content/PreviewFrame.razor.css index 40e60ea..62e81cd 100644 --- a/src/ShellDocs.Components/Content/PreviewFrame.razor.css +++ b/src/ShellDocs.Components/Content/PreviewFrame.razor.css @@ -6,64 +6,6 @@ margin: 1.5rem 0; } -.preview-tabs { - display: flex; - align-items: center; - gap: 0.15rem; - padding: 0.35rem 0.4rem; - background: color-mix(in oklch, var(--card) 55%, var(--background)); - border-bottom: 1px solid var(--border); -} - -.preview-tab { - display: inline-flex; - align-items: center; - gap: 0.4rem; - padding: 0.35rem 0.7rem; - background: transparent; - border: 0; - border-radius: calc(var(--radius) - 3px); - color: var(--muted-foreground); - font-family: inherit; - font-size: 0.8125rem; - font-weight: 500; - cursor: pointer; - transition: color 150ms, background 150ms; -} -.preview-tab:hover { color: var(--foreground); } -.preview-tab.active { - color: var(--foreground); - background: var(--muted); -} -.preview-tab svg { width: 0.875rem; height: 0.875rem; } - -.preview-spacer { flex: 1; } - -.preview-copy { - display: inline-flex; - align-items: center; - gap: 0.35rem; - padding: 0.3rem 0.55rem 0.3rem 0.5rem; - background: transparent; - border: 1px solid transparent; - border-radius: calc(var(--radius) - 3px); - color: var(--muted-foreground); - font-family: inherit; - font-size: 0.75rem; - font-weight: 500; - cursor: pointer; - transition: color 150ms, background 150ms, border-color 150ms; -} -.preview-copy:hover { - color: var(--foreground); - background: var(--muted); - border-color: var(--border); -} -.preview-copy.copied { color: var(--success, oklch(0.723 0.219 149.579)); } -.preview-copy svg { width: 0.8125rem; height: 0.8125rem; } - -.preview-body { position: relative; } - .preview-render { display: flex; align-items: center; @@ -79,6 +21,16 @@ transparent 1px, transparent 8px); } +.preview-code-wrap { + position: relative; + border-top: 1px solid var(--border); + background: color-mix(in oklch, var(--card) 55%, var(--background)); + overflow: hidden; + transition: max-height 300ms ease; +} +.preview-frame.collapsed .preview-code-wrap { max-height: 8.5rem; } +.preview-frame.expanded .preview-code-wrap { max-height: none; } + .preview-code { margin: 0; padding: 1.15rem 1.25rem; @@ -97,3 +49,71 @@ font-size: inherit !important; color: inherit !important; } + +.preview-fade { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient( + to bottom, + transparent 0%, + color-mix(in oklch, var(--card) 40%, transparent) 35%, + var(--card) 75%); + pointer-events: none; +} + +.preview-expand { + pointer-events: auto; + background: var(--card); + border: 1px solid var(--border); + border-radius: calc(var(--radius) - 2px); + color: var(--foreground); + padding: 0.45rem 1rem; + font-family: inherit; + font-size: 0.8125rem; + font-weight: 500; + cursor: pointer; + box-shadow: 0 1px 2px color-mix(in oklch, var(--foreground) 8%, transparent); + transition: background 150ms, border-color 150ms; +} +.preview-expand:hover { + background: var(--muted); + border-color: color-mix(in oklch, var(--border) 60%, var(--foreground)); +} + +.preview-code-actions { + position: absolute; + top: 0.55rem; + right: 0.6rem; + display: flex; + align-items: center; + gap: 0.35rem; + z-index: 1; +} + +.preview-copy, +.preview-collapse { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.3rem 0.55rem; + background: color-mix(in oklch, var(--card) 92%, var(--foreground)); + border: 1px solid var(--border); + border-radius: calc(var(--radius) - 3px); + color: var(--muted-foreground); + font-family: inherit; + font-size: 0.75rem; + font-weight: 500; + cursor: pointer; + transition: color 150ms, background 150ms, border-color 150ms; +} +.preview-copy:hover, +.preview-collapse:hover { + color: var(--foreground); + background: var(--muted); + border-color: color-mix(in oklch, var(--border) 60%, var(--foreground)); +} +.preview-copy.copied { color: var(--success, oklch(0.723 0.219 149.579)); } +.preview-copy svg { width: 0.8125rem; height: 0.8125rem; } diff --git a/src/ShellDocs.Components/Content/SlotRenderer.cs b/src/ShellDocs.Components/Content/SlotRenderer.cs new file mode 100644 index 0000000..828b59c --- /dev/null +++ b/src/ShellDocs.Components/Content/SlotRenderer.cs @@ -0,0 +1,119 @@ +using System.Globalization; +using System.Reflection; +using System.Text; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; +using ShellDocs.Markdown; + +namespace ShellDocs.Components.Content; + +/* Turns a slice of markdown (or raw HTML-with-component-tags) into a Blazor + RenderFragment that renders nested components as real DynamicComponents, + recursively — so and friends work + inside razor:preview blocks and inline ChildContent. */ +internal static class SlotRenderer +{ + public static RenderFragment FromMarkup(MarkdownRenderer renderer, string raw) => builder => + { + /* Markdig treats any block indented 4+ spaces as a code block, so + children of , , etc. authored with the outer tag's + natural indent would render as literal
     instead of components.
    +           Strip the common leading whitespace before feeding to the renderer. */
    +        var doc = renderer.Render(Dedent(raw));
    +        var parts = SlotSplitter.Split(doc);
    +        var seq = 0;
    +        foreach (var part in parts)
    +        {
    +            if (part is HtmlPart html)
    +            {
    +                builder.AddMarkupContent(seq++, html.Html);
    +            }
    +            else if (part is SlotPart s && s.Slot is ComponentSlot comp)
    +            {
    +                Emit(builder, ref seq, renderer, comp);
    +            }
    +        }
    +    };
    +
    +    private static void Emit(RenderTreeBuilder builder, ref int seq, MarkdownRenderer renderer, ComponentSlot slot)
    +    {
    +        builder.OpenComponent(seq++);
    +        builder.AddAttribute(seq++, "Type", slot.ComponentType);
    +        builder.AddAttribute(seq++, "Parameters", BuildParameters(renderer, slot.ComponentType, slot.Parameters, slot.ChildContentRaw));
    +        builder.CloseComponent();
    +    }
    +
    +    private static string Dedent(string raw)
    +    {
    +        var lines = raw.Split('\n');
    +        var minIndent = int.MaxValue;
    +        foreach (var line in lines)
    +        {
    +            if (string.IsNullOrWhiteSpace(line)) continue;
    +            var indent = 0;
    +            while (indent < line.Length && (line[indent] == ' ' || line[indent] == '\t')) indent++;
    +            if (indent < minIndent) minIndent = indent;
    +        }
    +        if (minIndent <= 0 || minIndent == int.MaxValue) return raw;
    +        var sb = new StringBuilder(raw.Length);
    +        for (var i = 0; i < lines.Length; i++)
    +        {
    +            var line = lines[i].TrimEnd('\r');
    +            if (line.Length >= minIndent && !string.IsNullOrWhiteSpace(line))
    +                sb.Append(line.AsSpan(minIndent));
    +            else
    +                sb.Append(line);
    +            if (i < lines.Length - 1) sb.Append('\n');
    +        }
    +        return sb.ToString();
    +    }
    +
    +    public static IDictionary BuildParameters(
    +        MarkdownRenderer renderer,
    +        Type componentType,
    +        IReadOnlyDictionary attrs,
    +        string? childContentRaw)
    +    {
    +        var dict = new Dictionary(StringComparer.Ordinal);
    +        var props = GetParameterProps(componentType);
    +        foreach (var (k, v) in attrs)
    +        {
    +            dict[k] = props.TryGetValue(k, out var prop) ? Coerce(v, prop.PropertyType) : v;
    +        }
    +        if (!string.IsNullOrWhiteSpace(childContentRaw))
    +        {
    +            dict["ChildContent"] = FromMarkup(renderer, childContentRaw);
    +        }
    +        return dict;
    +    }
    +
    +    private static readonly Dictionary> _propCache = new();
    +
    +    private static Dictionary GetParameterProps(Type t)
    +    {
    +        lock (_propCache)
    +        {
    +            if (_propCache.TryGetValue(t, out var cached)) return cached;
    +            var map = new Dictionary(StringComparer.Ordinal);
    +            foreach (var p in t.GetProperties(BindingFlags.Public | BindingFlags.Instance))
    +            {
    +                if (p.GetCustomAttribute() is not null) map[p.Name] = p;
    +            }
    +            _propCache[t] = map;
    +            return map;
    +        }
    +    }
    +
    +    private static object Coerce(string raw, Type target)
    +    {
    +        var underlying = Nullable.GetUnderlyingType(target) ?? target;
    +        if (underlying == typeof(string)) return raw;
    +        if (underlying == typeof(bool)) return bool.Parse(raw);
    +        if (underlying.IsEnum) return Enum.Parse(underlying, raw, ignoreCase: true);
    +        if (underlying == typeof(int)) return int.Parse(raw, CultureInfo.InvariantCulture);
    +        if (underlying == typeof(long)) return long.Parse(raw, CultureInfo.InvariantCulture);
    +        if (underlying == typeof(double)) return double.Parse(raw, CultureInfo.InvariantCulture);
    +        if (underlying == typeof(decimal)) return decimal.Parse(raw, CultureInfo.InvariantCulture);
    +        return raw;
    +    }
    +}
    
    From 3da06d044db4cf651511b823b05d0ae9e7cf58dc Mon Sep 17 00:00:00 2001
    From: Shephard Tseisi 
    Date: Tue, 21 Jul 2026 23:34:00 +0200
    Subject: [PATCH 5/7] feat: add CodeGroupSyncState and auto-register additional
     content components in ServiceCollectionExtensions, improving component
     accessibility in markdown previews
    
    ---
     .../ServiceCollectionExtensions.cs              | 17 +++++++++++++++++
     1 file changed, 17 insertions(+)
    
    diff --git a/src/ShellDocs.Components/ServiceCollectionExtensions.cs b/src/ShellDocs.Components/ServiceCollectionExtensions.cs
    index c18c1b9..a2b6859 100644
    --- a/src/ShellDocs.Components/ServiceCollectionExtensions.cs
    +++ b/src/ShellDocs.Components/ServiceCollectionExtensions.cs
    @@ -1,5 +1,6 @@
     using Microsoft.Extensions.DependencyInjection;
     using ShellDocs.Components.Chrome;
    +using ShellDocs.Components.Content;
     using ShellDocs.Core;
     using ShellDocs.Markdown;
     
    @@ -17,6 +18,22 @@ public static IServiceCollection AddShellDocs(this IServiceCollection services,
             services.AddScoped();
             services.AddScoped();
             services.AddScoped();
    +        services.AddScoped();
    +
    +        // Auto-register the shipped content primitives so `razor:preview` blocks
    +        // in markdown can reference , , ,  etc.
    +        // without the consumer calling RegisterComponent() themselves.
    +        options.RegisterComponent();
    +        options.RegisterComponent();
    +        options.RegisterComponent();
    +        options.RegisterComponent();
    +        options.RegisterComponent();
    +        options.RegisterComponent();
    +        options.RegisterComponent();
    +        options.RegisterComponent();
    +        options.RegisterComponent();
    +        options.RegisterComponent();
    +
             services.AddSingleton(_ => options.BuildTypeRegistry());
             services.AddSingleton(sp => new MarkdownRenderer(sp.GetRequiredService()));
     
    
    From 43c002b1843961f7451cc21abf560a361bb6d96b Mon Sep 17 00:00:00 2001
    From: Shephard Tseisi 
    Date: Tue, 21 Jul 2026 23:34:18 +0200
    Subject: [PATCH 6/7] feat: extend PreviewSlot to include optional
     ChildContentRaw for improved rendering of non-self-closing tags in markdown
    
    ---
     src/ShellDocs.Markdown/RenderedDocument.cs |  3 ++-
     src/ShellDocs.Markdown/SlotExtractor.cs    | 16 +++++++++++++++-
     2 files changed, 17 insertions(+), 2 deletions(-)
    
    diff --git a/src/ShellDocs.Markdown/RenderedDocument.cs b/src/ShellDocs.Markdown/RenderedDocument.cs
    index cbec426..79a39cb 100644
    --- a/src/ShellDocs.Markdown/RenderedDocument.cs
    +++ b/src/ShellDocs.Markdown/RenderedDocument.cs
    @@ -21,4 +21,5 @@ public record PreviewSlot(
         Type ComponentType,
         IReadOnlyDictionary Parameters,
         string Code,
    -    string Language) : Slot(Id);
    +    string Language,
    +    string? ChildContentRaw = null) : Slot(Id);
    diff --git a/src/ShellDocs.Markdown/SlotExtractor.cs b/src/ShellDocs.Markdown/SlotExtractor.cs
    index 47b10a4..255f9e0 100644
    --- a/src/ShellDocs.Markdown/SlotExtractor.cs
    +++ b/src/ShellDocs.Markdown/SlotExtractor.cs
    @@ -148,7 +148,21 @@ private string ReplaceComponentTags(string text, List slots, List
             }
     
             var attrs = ParseAttributes(open.Groups["attrs"].Value);
    -        return new PreviewSlot(NewSlotId(), type, attrs, code, "razor");
    +
    +        // Extract inner ChildContent for non-self-closing tags. e.g.
    +        //   ... ...
    +        // The inner text is what ends up injected as a RenderFragment at render time.
    +        string? childContentRaw = null;
    +        if (!open.Groups["self"].Success)
    +        {
    +            var range = FindMatchingClose(code, name, open.Index + open.Length);
    +            if (range.Start >= 0)
    +            {
    +                childContentRaw = code.Substring(open.Index + open.Length, range.Start - (open.Index + open.Length));
    +            }
    +        }
    +
    +        return new PreviewSlot(NewSlotId(), type, attrs, code, "razor", childContentRaw);
         }
     
         private static (int Start, int End) FindMatchingClose(string text, string name, int fromIndex)
    
    From 53792f2f4dd9f2769b014ef0234fde50f7cbff1c Mon Sep 17 00:00:00 2001
    From: Shephard Tseisi 
    Date: Tue, 21 Jul 2026 23:34:32 +0200
    Subject: [PATCH 7/7] refactor: update AddShellDocsTests to replace Callout
     with CustomWidget, ensuring accurate component registration in TypeRegistry
    
    ---
     tests/ShellDocs.Tests/AddShellDocsTests.cs | 6 +++---
     1 file changed, 3 insertions(+), 3 deletions(-)
    
    diff --git a/tests/ShellDocs.Tests/AddShellDocsTests.cs b/tests/ShellDocs.Tests/AddShellDocsTests.cs
    index c366d45..a49237f 100644
    --- a/tests/ShellDocs.Tests/AddShellDocsTests.cs
    +++ b/tests/ShellDocs.Tests/AddShellDocsTests.cs
    @@ -36,11 +36,11 @@ public void AddShellDocs_MissingContentRoot_ReturnsEmptyGraph()
         public void AddShellDocs_RegisteredComponents_FlowIntoTypeRegistry()
         {
             var services = new ServiceCollection();
    -        services.AddShellDocs(o => o.RegisterComponent());
    +        services.AddShellDocs(o => o.RegisterComponent());
             var sp = services.BuildServiceProvider();
     
             var registry = sp.GetRequiredService();
    -        Assert.Equal(typeof(Callout), registry.Resolve("Callout"));
    +        Assert.Equal(typeof(CustomWidget), registry.Resolve("CustomWidget"));
         }
     
         [Fact]
    @@ -55,5 +55,5 @@ public void ShellDocsOptions_FluentAddNavLink_AppendsInOrder()
             Assert.Equal("/blog", options.PrimaryNav[1].Href);
         }
     
    -    public class Callout : Microsoft.AspNetCore.Components.ComponentBase { }
    +    public class CustomWidget : Microsoft.AspNetCore.Components.ComponentBase { }
     }