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
Original file line number Diff line number Diff line change
@@ -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

`<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

<ComponentPreview Component="Callout" Variant="info" Title="Heads up">
Body content that becomes the Callout's ChildContent.
</ComponentPreview>

## Self-closing

<ComponentPreview Component="LinkCard" Title="Getting started" Description="Install ShellDocs and scaffold your first docs site." Href="/docs/quick-start" />

## 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; `<ComponentPreview>` is optimised for single-component prop-focused examples.
Original file line number Diff line number Diff line change
@@ -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"]
}
31 changes: 31 additions & 0 deletions examples/ShellDocs.Preview/content/docs/components/type-table.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
title: TypeTable
description: Structured props / API reference table for a component or type.
category: Components
order: 60
---

# TypeTable

`<TypeTable>` is the props / API reference primitive. Nest `<TypeRow>` 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

<TypeTable>
<TypeRow Name="Variant" Type="string" Default="info" Description="One of info | warning | danger | tip." />
<TypeRow Name="Title" Type="string" Description="Bold heading line above the body." />
<TypeRow Name="ChildContent" Type="RenderFragment" Description="Body content — markdown or nested Razor." Required="true" />
</TypeTable>

## Props

- `Name` — the prop name shown in the first column (renders as `<code>`)
- `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`.
138 changes: 138 additions & 0 deletions src/ShellDocs.Components/Content/ComponentPreview.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
@namespace ShellDocs.Components.Content
@using System.Reflection
@using System.Text
@using ShellDocs.Markdown
@inject TypeRegistry Registry
@inject IJSRuntime JS

<div class="component-preview @(_showSource ? "expanded" : "collapsed")">
<div class="component-preview-render">
@if (_target is not null && _targetParams is not null)
{
<DynamicComponent Type="_target" Parameters="_targetParams" />
}
else
{
<div class="component-preview-error">
Unknown component: <code>@Component</code>
</div>
}
</div>
<div class="component-preview-source-wrap">
<pre class="component-preview-source language-razor" @ref="_sourceEl"><code class="language-razor">@_source</code></pre>
@if (!_showSource)
{
<div class="component-preview-fade">
<button type="button" class="component-preview-expand" @onclick="Show">View source</button>
</div>
}
else
{
<div class="component-preview-actions">
<button type="button" class="component-preview-copy @(_copied ? "copied" : "")" @onclick="Copy" aria-label="Copy source">
@if (_copied)
{
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
}
else
{
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
}
</button>
<button type="button" class="component-preview-hide" @onclick="Hide">Hide</button>
</div>
}
</div>
</div>

@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<string, object>? ExtraProps { get; set; }

private Type? _target;
private IDictionary<string, object>? _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<string, object> BuildTargetParams(Type target)
{
var dict = new Dictionary<string, object>(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("</").Append(Component).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 { }
}
}
130 changes: 130 additions & 0 deletions src/ShellDocs.Components/Content/ComponentPreview.razor.css
Original file line number Diff line number Diff line change
@@ -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; }
10 changes: 8 additions & 2 deletions src/ShellDocs.Components/Content/SlotRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -83,13 +83,19 @@ public static IDictionary<string, object> 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<Type, Dictionary<string, PropertyInfo>> _propCache = new();

private static Dictionary<string, PropertyInfo> GetParameterProps(Type t)
internal static Dictionary<string, PropertyInfo> GetParameterProps(Type t)
{
lock (_propCache)
{
Expand All @@ -104,7 +110,7 @@ private static Dictionary<string, PropertyInfo> 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;
Expand Down
Loading
Loading