From 5e2cd5c818095bcb65f2abf96432d2b6fada1f80 Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Wed, 22 Jul 2026 00:16:57 +0200 Subject: [PATCH 1/3] feat: add ComponentPreview and TypeTable components for enhanced documentation examples and structured prop references --- .../docs/components/component-preview.md | 32 +++++++++++++++++++ .../content/docs/components/meta.json | 2 +- .../content/docs/components/type-table.md | 31 ++++++++++++++++++ 3 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 examples/ShellDocs.Preview/content/docs/components/component-preview.md create mode 100644 examples/ShellDocs.Preview/content/docs/components/type-table.md diff --git a/examples/ShellDocs.Preview/content/docs/components/component-preview.md b/examples/ShellDocs.Preview/content/docs/components/component-preview.md new file mode 100644 index 0000000..0c30cd9 --- /dev/null +++ b/examples/ShellDocs.Preview/content/docs/components/component-preview.md @@ -0,0 +1,32 @@ +--- +title: ComponentPreview +description: Live-render a registered component by name with declarative props and a reveal-on-click source view. +category: Components +order: 65 +--- + +# ComponentPreview + +`` is the declarative-prop cousin of the `razor:preview` fence. Instead of authoring a full razor snippet inside a fenced code block, you pass the target component's **name** as a string plus its props as attributes, and ShellDocs renders it live — the source view is reconstructed from those same props on demand. + +## Basic + + +Body content that becomes the Callout's ChildContent. + + +## Self-closing + + + +## Props + +- `Component` — required. The registered tag name (e.g. `"Callout"`, `"Card"`, `"LinkCard"`) to render. Resolved through the same `TypeRegistry` that backs `razor:preview`, so any component `AddShellDocs` registers works here. +- Any other attribute — forwarded to the target component. Attribute values are strings in the markdown; ShellDocs coerces them to each target property's declared type (`bool`, `int`, enums, etc.) at render time. +- `ChildContent` — the tag body becomes the target's `ChildContent` render fragment. + +## Notes + +- The reconstructed source string is sorted by attribute name for stability and shows the tag as self-closing when there's no body. +- If `Component` doesn't resolve, the render slot shows an inline `Unknown component:` error instead of throwing. +- Prefer `razor:preview` fences for multi-component demos; `` is optimised for single-component prop-focused examples. diff --git a/examples/ShellDocs.Preview/content/docs/components/meta.json b/examples/ShellDocs.Preview/content/docs/components/meta.json index 5b936ee..15a0817 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", "card", "code-block", "code-group", "steps", "tabs", "filetree"] + "pages": ["callout", "card", "code-block", "code-group", "steps", "tabs", "filetree", "type-table", "component-preview"] } diff --git a/examples/ShellDocs.Preview/content/docs/components/type-table.md b/examples/ShellDocs.Preview/content/docs/components/type-table.md new file mode 100644 index 0000000..22ffe55 --- /dev/null +++ b/examples/ShellDocs.Preview/content/docs/components/type-table.md @@ -0,0 +1,31 @@ +--- +title: TypeTable +description: Structured props / API reference table for a component or type. +category: Components +order: 60 +--- + +# TypeTable + +`` is the props / API reference primitive. Nest `` children — one per prop — and the parent table renders a clean four-column layout (Prop / Type / Default / Description) with type-code chips and a `required` badge. + +## Basic + + + + + + + +## Props + +- `Name` — the prop name shown in the first column (renders as ``) +- `Type` — the type signature, e.g. `string`, `bool`, `int?`, `RenderFragment` +- `Default` — literal default value, omit for none (renders as `—`) +- `Description` — free-text explanation, right-aligned column +- `Required` — badge next to the name when the prop must be supplied + +## Notes + +- Rows render in source order, deduplicated by `Name` — repeated names silently drop. +- Type auto-generation from XML doc comments ships in v2 via `ShellDocs.Xml`. From d850aa9cf59eade9ae786ed9747cd6847f3c981c Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Wed, 22 Jul 2026 00:17:07 +0200 Subject: [PATCH 2/3] feat: introduce ComponentPreview, TypeRow, and TypeTable components for improved documentation display and prop management --- .../Content/ComponentPreview.razor | 138 ++++++++++++++++++ .../Content/ComponentPreview.razor.css | 130 +++++++++++++++++ .../Content/SlotRenderer.cs | 10 +- .../Content/Steps.razor.css | 30 ++-- .../Content/TypeRow.razor | 16 ++ .../Content/TypeRowInfo.cs | 3 + .../Content/TypeTable.razor | 79 ++++++++++ .../Content/TypeTable.razor.css | 79 ++++++++++ 8 files changed, 471 insertions(+), 14 deletions(-) create mode 100644 src/ShellDocs.Components/Content/ComponentPreview.razor create mode 100644 src/ShellDocs.Components/Content/ComponentPreview.razor.css create mode 100644 src/ShellDocs.Components/Content/TypeRow.razor create mode 100644 src/ShellDocs.Components/Content/TypeRowInfo.cs create mode 100644 src/ShellDocs.Components/Content/TypeTable.razor create mode 100644 src/ShellDocs.Components/Content/TypeTable.razor.css diff --git a/src/ShellDocs.Components/Content/ComponentPreview.razor b/src/ShellDocs.Components/Content/ComponentPreview.razor new file mode 100644 index 0000000..439510d --- /dev/null +++ b/src/ShellDocs.Components/Content/ComponentPreview.razor @@ -0,0 +1,138 @@ +@namespace ShellDocs.Components.Content +@using System.Reflection +@using System.Text +@using ShellDocs.Markdown +@inject TypeRegistry Registry +@inject IJSRuntime JS + +
+
+ @if (_target is not null && _targetParams is not null) + { + + } + else + { +
+ Unknown component: @Component +
+ } +
+
+
@_source
+ @if (!_showSource) + { +
+ +
+ } + else + { +
+ + +
+ } +
+
+ +@code { + [Parameter, EditorRequired] public string? Component { get; set; } + [Parameter] public RenderFragment? ChildContent { get; set; } + /* Threaded in by SlotRenderer alongside ChildContent when the tag has body + content — used to reconstruct the source-view string. */ + [Parameter] public string? ChildContentSource { get; set; } + [Parameter(CaptureUnmatchedValues = true)] + public IReadOnlyDictionary? ExtraProps { get; set; } + + private Type? _target; + private IDictionary? _targetParams; + private string? _source; + private bool _showSource; + private bool _copied; + private bool _highlighted; + private ElementReference _sourceEl; + + protected override void OnParametersSet() + { + _target = Component is null ? null : Registry.Resolve(Component); + _targetParams = _target is null ? null : BuildTargetParams(_target); + _source = _target is null ? null : BuildSource(); + } + + private IDictionary BuildTargetParams(Type target) + { + var dict = new Dictionary(StringComparer.Ordinal); + var props = SlotRenderer.GetParameterProps(target); + if (ExtraProps is not null) + { + foreach (var (k, v) in ExtraProps) + { + dict[k] = (v is string s && props.TryGetValue(k, out var prop)) + ? SlotRenderer.Coerce(s, prop.PropertyType) + : v; + } + } + if (ChildContent is not null) dict["ChildContent"] = ChildContent; + return dict; + } + + private string BuildSource() + { + var sb = new StringBuilder(); + sb.Append('<').Append(Component); + if (ExtraProps is not null) + { + foreach (var (k, v) in ExtraProps.OrderBy(x => x.Key, StringComparer.Ordinal)) + { + sb.Append(' ').Append(k).Append("=\"").Append(v).Append('"'); + } + } + var body = ChildContentSource?.Trim(); + if (string.IsNullOrEmpty(body)) + { + sb.Append(" />"); + } + else + { + sb.Append('>').Append(body).Append("'); + } + return sb.ToString(); + } + + private void Show() => _showSource = true; + private void Hide() => _showSource = false; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender && !_highlighted && _source is not null) + { + _highlighted = true; + try { await JS.InvokeVoidAsync("shelldocsHighlightElement", _sourceEl); } catch { } + } + } + + private async Task Copy() + { + if (_source is null) return; + try + { + await JS.InvokeVoidAsync("navigator.clipboard.writeText", _source); + _copied = true; + StateHasChanged(); + await Task.Delay(1400); + _copied = false; + StateHasChanged(); + } + catch { } + } +} diff --git a/src/ShellDocs.Components/Content/ComponentPreview.razor.css b/src/ShellDocs.Components/Content/ComponentPreview.razor.css new file mode 100644 index 0000000..ec63157 --- /dev/null +++ b/src/ShellDocs.Components/Content/ComponentPreview.razor.css @@ -0,0 +1,130 @@ +.component-preview { + border: 1px solid var(--border); + border-radius: calc(var(--radius) + 2px); + background: var(--card); + overflow: hidden; + margin: 1.5rem 0; +} + +.component-preview-render { + display: flex; + align-items: center; + justify-content: center; + gap: 1rem; + flex-wrap: wrap; + min-height: 12rem; + padding: 2rem 1.5rem; + background: + repeating-linear-gradient(45deg, + color-mix(in oklch, var(--foreground) 2.5%, transparent) 0, + color-mix(in oklch, var(--foreground) 2.5%, transparent) 1px, + transparent 1px, transparent 8px); +} + +.component-preview-error { + color: var(--destructive, oklch(0.577 0.245 27.325)); + font-family: var(--font-mono); + font-size: 0.8125rem; +} +.component-preview-error code { + background: color-mix(in oklch, var(--destructive, oklch(0.577 0.245 27.325)) 12%, transparent); + padding: 0.1rem 0.4rem; + border-radius: calc(var(--radius) - 4px); +} + +.component-preview-source-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; +} +.component-preview.collapsed .component-preview-source-wrap { max-height: 6rem; } +.component-preview.expanded .component-preview-source-wrap { max-height: none; } + +.component-preview-source { + margin: 0; + padding: 1.15rem 1.25rem; + background: transparent; + font-family: var(--font-mono); + font-size: 0.8125rem; + line-height: 1.65; + color: var(--foreground); + overflow-x: auto; +} +.component-preview-source code { + background: transparent !important; + border: 0 !important; + padding: 0 !important; + font-family: var(--font-mono) !important; + font-size: inherit !important; + color: inherit !important; +} + +.component-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; +} + +.component-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; +} +.component-preview-expand:hover { + background: var(--muted); + border-color: color-mix(in oklch, var(--border) 60%, var(--foreground)); +} + +.component-preview-actions { + position: absolute; + top: 0.55rem; + right: 0.6rem; + display: flex; + align-items: center; + gap: 0.35rem; + z-index: 1; +} + +.component-preview-copy, +.component-preview-hide { + 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; +} +.component-preview-copy:hover, +.component-preview-hide:hover { + color: var(--foreground); + background: var(--muted); + border-color: color-mix(in oklch, var(--border) 60%, var(--foreground)); +} +.component-preview-copy.copied { color: var(--success, oklch(0.723 0.219 149.579)); } +.component-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 index 828b59c..04655c3 100644 --- a/src/ShellDocs.Components/Content/SlotRenderer.cs +++ b/src/ShellDocs.Components/Content/SlotRenderer.cs @@ -83,13 +83,19 @@ public static IDictionary BuildParameters( if (!string.IsNullOrWhiteSpace(childContentRaw)) { dict["ChildContent"] = FromMarkup(renderer, childContentRaw); + /* If the target declares a ChildContentSource [Parameter] (as + ComponentPreview does for reconstructing its source view), + pass the raw markup through unchanged in addition to the + RenderFragment above. */ + if (props.ContainsKey("ChildContentSource")) + dict["ChildContentSource"] = childContentRaw; } return dict; } private static readonly Dictionary> _propCache = new(); - private static Dictionary GetParameterProps(Type t) + internal static Dictionary GetParameterProps(Type t) { lock (_propCache) { @@ -104,7 +110,7 @@ private static Dictionary GetParameterProps(Type t) } } - private static object Coerce(string raw, Type target) + internal static object Coerce(string raw, Type target) { var underlying = Nullable.GetUnderlyingType(target) ?? target; if (underlying == typeof(string)) return raw; diff --git a/src/ShellDocs.Components/Content/Steps.razor.css b/src/ShellDocs.Components/Content/Steps.razor.css index d596aae..66083b4 100644 --- a/src/ShellDocs.Components/Content/Steps.razor.css +++ b/src/ShellDocs.Components/Content/Steps.razor.css @@ -1,16 +1,18 @@ .steps { list-style: none; counter-reset: step; + /* Push the whole rail right so the border-left lives at x = 1rem, which is + where the ::before badges get centered via the negative-left offset below. */ + margin: 1.5rem 0 1.5rem 1rem; 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; + padding: 0.15rem 0 1.75rem 2.5rem; + min-height: 2.25rem; color: var(--foreground); } ::deep .step:last-child { padding-bottom: 0; } @@ -18,11 +20,14 @@ ::deep .step::before { content: counter(step); position: absolute; - left: -1.05rem; + /* Badge is 2rem wide; pulling it left by (badge-width / 2 + rail-half-px) + centers it on the 1px rail. */ + left: calc(-1rem - 0.5px); top: 0; - width: 1.85rem; - height: 1.85rem; - background: var(--muted); + width: 2rem; + height: 2rem; + box-sizing: border-box; + background: var(--card); color: var(--foreground); border: 1px solid var(--border); border-radius: 9999px; @@ -30,18 +35,19 @@ align-items: center; justify-content: center; font-family: var(--font-mono); - font-size: 0.75rem; + font-size: 0.8125rem; font-weight: 600; letter-spacing: -0.02em; + line-height: 1; } ::deep .step-title { - margin: 0 0 0.35rem; - font-size: 1.05rem; + margin: 0 0 0.4rem; + font-size: 1rem; font-weight: 600; letter-spacing: -0.015em; - line-height: 1.4; + line-height: 1.5; } -::deep .step-content { color: var(--muted-foreground); font-size: 0.9rem; line-height: 1.6; } +::deep .step-content { color: var(--muted-foreground); font-size: 0.9rem; line-height: 1.65; } ::deep .step-content > *:first-child { margin-top: 0; } ::deep .step-content > *:last-child { margin-bottom: 0; } diff --git a/src/ShellDocs.Components/Content/TypeRow.razor b/src/ShellDocs.Components/Content/TypeRow.razor new file mode 100644 index 0000000..5487905 --- /dev/null +++ b/src/ShellDocs.Components/Content/TypeRow.razor @@ -0,0 +1,16 @@ +@namespace ShellDocs.Components.Content + +@code { + [CascadingParameter] public TypeTable? Parent { get; set; } + + [Parameter, EditorRequired] public string Name { get; set; } = ""; + [Parameter] public string Type { get; set; } = ""; + [Parameter] public string? Default { get; set; } + [Parameter] public string? Description { get; set; } + [Parameter] public bool Required { get; set; } + + protected override void OnInitialized() + { + Parent?.Register(new TypeRowInfo(Name, Type, Default, Description, Required)); + } +} diff --git a/src/ShellDocs.Components/Content/TypeRowInfo.cs b/src/ShellDocs.Components/Content/TypeRowInfo.cs new file mode 100644 index 0000000..4c50114 --- /dev/null +++ b/src/ShellDocs.Components/Content/TypeRowInfo.cs @@ -0,0 +1,3 @@ +namespace ShellDocs.Components.Content; + +internal record TypeRowInfo(string Name, string Type, string? Default, string? Description, bool Required); diff --git a/src/ShellDocs.Components/Content/TypeTable.razor b/src/ShellDocs.Components/Content/TypeTable.razor new file mode 100644 index 0000000..277b65b --- /dev/null +++ b/src/ShellDocs.Components/Content/TypeTable.razor @@ -0,0 +1,79 @@ +@namespace ShellDocs.Components.Content + +
+ + + + + + + + + + + @if (_rows.Count == 0) + { + + + + } + else + { + @foreach (var row in _rows) + { + + + + + + + } + } + +
PropTypeDefaultDescription
No props documented.
+ @row.Name + @if (row.Required) + { + required + } + + @if (!string.IsNullOrEmpty(row.Type)) + { + @row.Type + } + else + { + + } + + @if (!string.IsNullOrEmpty(row.Default)) + { + @row.Default + } + else + { + + } + @row.Description
+
+ + + @* Consume ChildContent silently — TypeRow children register into _rows via + this cascading value; TypeRow itself renders nothing on its own. *@ +
@ChildContent
+
+ +@code { + [Parameter] public RenderFragment? ChildContent { get; set; } + + private readonly List _rows = new(); + + internal void Register(TypeRowInfo info) + { + if (_rows.Any(r => r.Name == info.Name)) return; + _rows.Add(info); + StateHasChanged(); + } +} + +@* Info record used by TypeRow to hand its data up to the table. *@ diff --git a/src/ShellDocs.Components/Content/TypeTable.razor.css b/src/ShellDocs.Components/Content/TypeTable.razor.css new file mode 100644 index 0000000..4515acb --- /dev/null +++ b/src/ShellDocs.Components/Content/TypeTable.razor.css @@ -0,0 +1,79 @@ +.type-table-wrap { + margin: 1.5rem 0; + border: 1px solid var(--border); + border-radius: calc(var(--radius) + 2px); + background: var(--card); + overflow-x: auto; +} + +.type-table { + width: 100%; + border-collapse: collapse; + font-size: 0.875rem; + line-height: 1.55; +} + +.type-table thead { + background: color-mix(in oklch, var(--card) 55%, var(--background)); + border-bottom: 1px solid var(--border); +} + +.type-table th { + text-align: left; + padding: 0.65rem 0.9rem; + color: var(--muted-foreground); + font-weight: 500; + font-size: 0.75rem; + letter-spacing: 0.02em; + text-transform: uppercase; + white-space: nowrap; +} + +.type-table tbody tr + tr { + border-top: 1px solid color-mix(in oklch, var(--border) 60%, transparent); +} + +.type-table td { + padding: 0.7rem 0.9rem; + vertical-align: top; + color: var(--foreground); +} + +.type-table code { + background: color-mix(in oklch, var(--card) 55%, var(--background)); + border: 1px solid var(--border); + border-radius: calc(var(--radius) - 4px); + padding: 0.05rem 0.4rem; + font-family: var(--font-mono); + font-size: 0.8125rem; + color: var(--foreground); + white-space: nowrap; +} + +.type-table-name code { color: var(--foreground); font-weight: 500; } +.type-table-type code { color: var(--primary); } +.type-table-default code { color: var(--muted-foreground); } + +.type-table-required { + margin-left: 0.5rem; + padding: 0.05rem 0.4rem; + 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-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; + white-space: nowrap; +} + +.type-table-muted { color: var(--muted-foreground); } +.type-table-desc { color: var(--muted-foreground); } +.type-table-desc code { display: inline; } + +.type-table-empty { + text-align: center; + padding: 1.5rem; + color: var(--muted-foreground); + font-style: italic; +} From 8852f71c68fc6cbbc23100106574cff1066adb72 Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Wed, 22 Jul 2026 00:17:22 +0200 Subject: [PATCH 3/3] feat: register TypeRow and ComponentPreview components to enhance documentation capabilities and improve prop management --- src/ShellDocs.Components/ServiceCollectionExtensions.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/ShellDocs.Components/ServiceCollectionExtensions.cs b/src/ShellDocs.Components/ServiceCollectionExtensions.cs index a2b6859..be40484 100644 --- a/src/ShellDocs.Components/ServiceCollectionExtensions.cs +++ b/src/ShellDocs.Components/ServiceCollectionExtensions.cs @@ -33,6 +33,9 @@ public static IServiceCollection AddShellDocs(this IServiceCollection services, options.RegisterComponent(); options.RegisterComponent(); options.RegisterComponent(); + options.RegisterComponent(); + options.RegisterComponent(); + options.RegisterComponent(); services.AddSingleton(_ => options.BuildTypeRegistry()); services.AddSingleton(sp => new MarkdownRenderer(sp.GetRequiredService()));