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

## [Unreleased]

### Added

- **Preview layout.** `razor:preview stretch` (fence info string), or `Layout="stretch"` on `<DemoPreview>` / `<ComponentPreview>` / `PreviewFrame`, lets block-level components (charts, inputs, tables) fill the frame instead of shrinking to their content. `center` stays the default.
- **`not-prose` class.** Every `.shelldocs-prose` rule skips `.not-prose` subtrees. Preview frames carry it; add it to any element that should keep page typography out.

### Changed

- **`razor:preview` child content is parsed as Razor, not markdown.** Component bodies inside a fence (and `<ComponentPreview>` bodies) go through the same node parser as the fence itself: elements wrap nested components exactly as written and nothing is wrapped in `<p>`. Inline component tags in prose still take markdown bodies.
- **`ThemeToggle` works without a Blazor runtime.** Clicks are handled by `shelldocs.js` and both icons render, with CSS picking one from `<html class="dark">`.

### Fixed

- **Prose styles leaked into previews.** Paragraph margins, list padding, heading sizes and link underlines from `.shelldocs-prose` hit live components and beat Tailwind's layered utilities (gaps inside cards and menus, underlined nav links).
- **Nested markup inside a preview component was mangled.** `<Navbar><div><a/>…<ThemeToggle/></div></Navbar>` closed the `<div>` before the nested component and wrapped loose text in `<p>`.
- **Theme state only followed ShellDocs' own toggle.** A component library flipping `<html class="dark">` left the stored theme and `ThemeState` stale. `shelldocs.js` now watches the class, saves it under `shelldocs-theme` and pushes it into `ThemeState`.
- **`enhancedload` handlers never ran.** Blazor raises the event through `Blazor.addEventListener`, not as a DOM event, so re-applying the theme, re-attaching the TOC and closing the mobile nav after enhanced navigation (static SSR apps) silently did nothing.

## [0.1.9-alpha] — 2026-10-02

A Preview | Code toolbar for every example, fixes for chrome that reset or ignored early clicks, consumer components no longer losing to built-ins with the same name, and code spans that stay code.
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ Already have a Blazor project? Run `shelldocs init --attach` inside it: it adds
```
Your components win name collisions with ShellDocs' built-ins, which stay available as `<DocsCard>`, `<DocsCallout>`, `<DocsTabs>`, and so on.
- **Content primitives.** `Callout`, `Card` / `CardGrid` / `LinkCard`, `Steps`, `FileTree`, `Tabs`, `CodeGroup`, `TypeTable` / `AutoTypeTable`, `ComponentPreview`, `DemoPreview`.
- **Static export.** `shelldocs build` prerenders every page to static HTML for GitHub Pages, Cloudflare Pages, Netlify or S3. Optional flags rewrite `<base href>` (`--base-href`), add a SPA `404.html` (`--spa-fallback`), and write sitemap / robots / `og:` meta (`--site-url`). Navigation, sidebar sections, the mobile menu, selectors, tabs, preview toolbars, the TOC and code copy work there through `shelldocs.js`. Search, the theme-toggle button, desktop sidebar collapse and stateful demos need a running Blazor app.
- **Static export.** `shelldocs build` prerenders every page to static HTML for GitHub Pages, Cloudflare Pages, Netlify or S3. Optional flags rewrite `<base href>` (`--base-href`), add a SPA `404.html` (`--spa-fallback`), and write sitemap / robots / `og:` meta (`--site-url`). Navigation, sidebar sections, the mobile menu, selectors, tabs, preview toolbars, the TOC and code copy work there through `shelldocs.js`. So does the theme toggle. Search, desktop sidebar collapse and stateful demos need a running Blazor app.

## Versioned docs

Expand Down Expand Up @@ -69,6 +69,9 @@ It's all server-rendered links plus `shelldocs.js`, so it works on static hosts.

- **Preview | Code toolbar on every example**, with copy and a ⋯ menu: *Open in new tab*, *Report a bug*, *Suggest something*. The issue links go to `https://github.com/{GitHubRepo}/issues/new`, pre-filled with the example and page URL; point them elsewhere with `o.IssueTrackerUrl`.
- **`razor:preview` fences render everything.** Several sibling components, HTML wrappers (`<div class="flex gap-2">…</div>`) and text render in order inside one frame, and the Code tab shows the whole fence.
- **Child content is Razor.** Component bodies inside a fence are parsed like the fence itself, so `<Navbar><div>…<ThemeToggle /></div></Navbar>` keeps its structure and nothing is wrapped in `<p>`.
- **Page typography stays out.** Preview frames are `not-prose`, so prose margins, list padding and link underlines don't reach your components.
- **Centred or stretched.** Examples are centred; `razor:preview stretch` (or `Layout="stretch"` on `<DemoPreview>` / `<ComponentPreview>`) lets charts, inputs and tables fill the frame.
- **Razor-shaped attribute values work:** `Variant="ButtonVariant.Destructive"`, `@ButtonVariant.Destructive`, `@true`, `@42`, `[Flags]` values as `Bold | Italic`.
- **Attributes a static preview can't evaluate are skipped, not fatal:** `OnClick="HandleClick"`, `@onclick`, `@bind-*`, `@ref`, non-primitive parameter types, unparseable values. Each logs a warning and the component still renders.
- **Inline code stays code.** `` `<Button>` `` in prose renders as literal code, not a component.
Expand Down
12 changes: 7 additions & 5 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ Placeholders plus a slot list keep everything at render time: no generated Razor

### `razor:preview` fences

`PreviewParser` turns the fence into ordered nodes: component nodes (registered capitalised tags; their children kept raw), element nodes (HTML, including unregistered capitalised tags) and text. `@code { }` / `@functions { }` blocks, `@* *@` comments and directive lines (`@using`, `@inject`, …) are skipped for rendering but stay in the Code tab. If no registered component is found the slot carries an `Error`, rendered as a red panel instead of a silent code block.
`PreviewParser` turns the fence into ordered nodes: component nodes (registered capitalised tags; their children kept raw and parsed the same way, as Razor, when the component renders), element nodes (HTML, including unregistered capitalised tags) and text. `@code { }` / `@functions { }` blocks, `@* *@` comments and directive lines (`@using`, `@inject`, …) are skipped for rendering but stay in the Code tab. If no registered component is found the slot carries an `Error`, rendered as a red panel instead of a silent code block. `razor:preview stretch` in the info string sets `PreviewSlot.Layout`.

---

Expand All @@ -124,11 +124,11 @@ Placeholders plus a slot list keep everything at render time: no generated Razor
- `ComponentSlot` → `<DynamicComponent>` with parameters from `SlotRenderer.BuildParameters`.
- `PreviewSlot` → `<PreviewFrame Id="preview-N">`, where `SlotRenderer.RenderNodes` emits elements and components as real render-tree nodes, so wrappers keep their children under interactive re-renders.

Component child markup is rendered recursively by `SlotRenderer.FromMarkup` (dedented first, since Markdig treats 4-space indentation as a code block). Child tags named after a `RenderFragment` parameter (`<Icon>` → `Alert.Icon`) are routed into that slot.
Component child markup is rendered recursively. Inline tags in prose take markdown bodies (`SlotRenderer.FromMarkup`, dedented first, since Markdig treats 4-space indentation as a code block). Inside a `razor:preview` and in `<ComponentPreview>` the body is Razor: `SlotRenderer.FromRazor` parses it with `PreviewParser` and emits real elements, so wrappers around nested components stay intact. Child tags named after a `RenderFragment` parameter (`<Icon>` → `Alert.Icon`) are routed into that slot.

**Parameter coercion.** Attribute values are strings. `BuildParameters` matches them to `[Parameter]` properties case-insensitively and coerces string, bool, char, numeric primitives and enums, accepting Razor forms: a leading `@`, `@( … )`, `Type.Member` enum values, `A | B` flags, numeric suffixes, `@null`. It never throws. Anything static markup can't set is skipped with a logged warning: directive attributes, delegate/`EventCallback` parameters, unsupported types, unparseable values, unknown attributes on components without a `CaptureUnmatchedValues` catch-all, and child content on components without a plain `RenderFragment ChildContent`.

**`PreviewFrame`** is the frame for every example: a toolbar with Preview | Code tabs, copy and a ⋯ menu (*Open in new tab* → the example's anchor; *Report a bug* / *Suggest something* → new-issue links built by `PreviewLinks` from `IssueTrackerUrl` or `GitHubRepo`). `ComponentPreview` and `DemoPreview` feed it a ready-made `Content` fragment and `Code` string instead of a `PreviewSlot`. `DemoPreview` reads its source from the first `{DemoSourceRoot}/**/X.razor` (shallowest path wins, cached, re-read when the file changes).
**`PreviewFrame`** is the frame for every example. It carries `not-prose` (every `.shelldocs-prose` rule skips `.not-prose` subtrees) and a `Layout` of `center` or `stretch`. It has a toolbar with Preview | Code tabs, copy and a ⋯ menu (*Open in new tab* → the example's anchor; *Report a bug* / *Suggest something* → new-issue links built by `PreviewLinks` from `IssueTrackerUrl` or `GitHubRepo`). `ComponentPreview` and `DemoPreview` feed it a ready-made `Content` fragment and `Code` string instead of a `PreviewSlot`. `DemoPreview` reads its source from the first `{DemoSourceRoot}/**/X.razor` (shallowest path wins, cached, re-read when the file changes).

**`DocsPageState`** (scoped) is fed by `MarkdownContent` and recomputes current node, prev/next and breadcrumbs on navigation, so the layout chrome needs no per-page wiring.

Expand Down Expand Up @@ -195,9 +195,11 @@ Chrome that must work without a Blazor runtime follows one contract:
| Tabs, CodeGroup | `[data-tabs]`, `[data-tab-target]`, `[data-tab-panel]` | switch on click / arrows; `[data-tabs-sync]` groups switch together and persist in `localStorage` |
| Mobile nav | page shell `[data-mobile-open]` | hamburger toggles; backdrop, Escape, link or navigation closes; page scroll locked on `<html>` |
| TOC | `[data-toc-list][data-toc-ids]` | IntersectionObserver scroll-spy, re-attached on `enhancedload` |
| Theme | `<html class="dark">` | inline head script applies the saved / system theme; re-applied after enhanced navigation |
| Theme | `<html class="dark">`, `[data-theme-toggle]` | inline head script applies the saved / system theme; the toggle flips the class and CSS picks its icon; a `MutationObserver` saves any change (including another library's toggle) and pushes it to `ThemeState`; re-applied after enhanced navigation |

Still Blazor-only: search, the `ThemeToggle` button, the desktop sidebar-collapse button.
Still Blazor-only: search, the desktop sidebar-collapse button.

`enhancedload` is a Blazor event (`Blazor.addEventListener`), not a DOM event; `shelldocsOnEnhancedLoad(fn)` attaches once `blazor.web.js` has run.

**Syntax highlighting.** Shiki (loaded by the app as `window.__shiki`) never replaces Blazor-owned nodes: the source `<pre>` gets `data-shiki="source"` (hidden by CSS) and the highlighted output goes into a JS-owned `[data-shiki-output]` sibling, re-rendered when the source text changes and removed when its source is gone.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ Body content that becomes the Callout's ChildContent.

- `Component` — required. The registered tag name (`"Callout"`, `"Card"`, `"LinkCard"`, …). Resolved through the same registry as `razor:preview`, so anything `AddShellDocs` or your `RegisterComponent*` calls register works.
- Any other attribute — forwarded to the target. Values are strings in markdown and are coerced to each parameter's type (`bool`, numbers, enums including `Type.Member` and `A | B` flags). Attributes that can't be set this way are skipped with a logged warning.
- The tag body becomes the target's `ChildContent`.
- `Layout` — `"center"` (default) or `"stretch"`, which lets block-level components fill the frame's width.
- The tag body is parsed as Razor (HTML and registered components, no markdown) and becomes the target's `ChildContent`. Child tags named after one of the target's `RenderFragment` parameters fill that slot.

## Notes

Expand Down
2 changes: 1 addition & 1 deletion examples/ShellDocs.Preview/content/docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,4 @@ shelldocs build
- `--spa-fallback` copies `index.html` to `404.html`.
- `--site-url https://docs.example.com` emits `sitemap.xml`, `robots.txt` and `og:` meta tags.

In the static output, navigation, sidebar sections, the mobile menu, the version and package selectors, tabs, preview tabs and menus, the table of contents and code copy all work through `shelldocs.js`. Search, the theme toggle, the desktop sidebar-collapse button and stateful demo components still need a running Blazor app (Server or WebAssembly).
In the static output, navigation, sidebar sections, the mobile menu, the version and package selectors, tabs, preview tabs and menus, the table of contents, the theme toggle and code copy all work through `shelldocs.js`. Search, the desktop sidebar-collapse button and stateful demo components still need a running Blazor app (Server or WebAssembly).
22 changes: 22 additions & 0 deletions examples/ShellDocs.Preview/content/docs/markdown-syntax.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,25 @@ A code fence with the info string `razor:preview` renders live in a preview fram
```

Attribute values accept Razor forms such as `Variant="ButtonVariant.Destructive"`, `@true` and `[Flags]` values like `Bold | Italic`. Attributes a static preview can't evaluate (`OnClick="Handler"`, `@bind-*`, `@ref`) are skipped with a logged warning. For demos that need `@code`, see `<DemoPreview>` in the README.

### Child content is Razor

Inside a fence, component bodies are Razor markup, not markdown. Elements wrap nested components exactly as written, and nothing is wrapped in `<p>`:

```razor:preview
<Card Title="Nested markup">
<div style="display: flex; gap: 0.75rem; align-items: center;"><span>Inside a div:</span><Callout Variant="tip" Text="a nested component" /></div>
</Card>
```

Inline component tags in prose (outside a fence) still take markdown bodies.

### Layout

Examples are centred. Add `stretch` to the info string (`razor:preview stretch`) so block-level components such as charts, inputs and tables fill the frame's width. `<DemoPreview>` and `<ComponentPreview>` take `Layout="stretch"`.

```razor:preview stretch
<Callout Variant="info" Text="This callout fills the frame instead of shrinking to its text." />
```

Preview frames are marked `not-prose`, so the page's typography (paragraph margins, list padding, link underlines) never reaches the components inside. Add the class to any other element that should opt out.
2 changes: 1 addition & 1 deletion examples/ShellDocs.Preview/content/docs/theming.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,4 @@ Override any of these in a stylesheet loaded after `tokens.css` and every compon

## Dark mode

Dark mode is the `dark` class on `<html>`. The `ThemeToggle` in the header / sidebar footer saves the choice in `localStorage` and falls back to `prefers-color-scheme` on the first visit; an inline script in `App.razor` applies it before first paint. The toggle button itself needs a running Blazor app; on a static build the saved or system preference still applies.
Dark mode is the `dark` class on `<html>`. The `ThemeToggle` in the header / sidebar footer saves the choice in `localStorage` and falls back to `prefers-color-scheme` on the first visit; an inline script in `App.razor` applies it before first paint. The toggle is handled by `shelldocs.js`, so it works on static builds too. Whatever flips the class, including a component library's own theme toggle, is saved the same way, so the choice sticks across pages.
47 changes: 25 additions & 22 deletions src/ShellDocs.Components/Chrome/ThemeToggle.razor
Original file line number Diff line number Diff line change
@@ -1,45 +1,48 @@
@using ShellIcons.Icons
@inject IJSRuntime JS
@inject ThemeState Theme
@implements IDisposable
@implements IAsyncDisposable

<button type="button" class="theme-toggle" @onclick="OnClick" aria-label="Toggle theme">
@if (Theme.IsDark)
{
<Moon />
}
else
{
<Sun />
}
@* Clicks are handled by shelldocs.js and CSS picks the icon from <html class="dark">,
so the toggle works without a Blazor runtime. *@
<button type="button" class="theme-toggle" data-theme-toggle aria-label="Toggle theme">
<Sun Class="theme-icon-light" />
<Moon Class="theme-icon-dark" />
</button>

@code {
protected override void OnInitialized() => Theme.OnChange += StateHasChanged;
private DotNetObjectReference<ThemeToggle>? _ref;
private IJSObjectReference? _subscription;

// Keeps ThemeState in step with the dark class, whoever flips it.
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender || Theme.IsInitialized) return;
if (!firstRender) return;
try
{
var current = await JS.InvokeAsync<string>("eval",
"document.documentElement.classList.contains('dark') ? 'dark' : 'light'");
Theme.Init(current == "dark");
_ref = DotNetObjectReference.Create(this);
_subscription = await JS.InvokeAsync<IJSObjectReference>("shelldocsTheme.subscribe", _ref);
var dark = await JS.InvokeAsync<bool>("shelldocsTheme.isDark");
if (Theme.IsInitialized) Theme.Set(dark);
else Theme.Init(dark);
}
catch { }
}

private async Task OnClick()
[JSInvokable]
public void ThemeChanged(bool isDark) => Theme.Set(isDark);

public async ValueTask DisposeAsync()
{
Theme.Toggle();
try
{
await JS.InvokeVoidAsync("eval", Theme.IsDark
? "document.documentElement.classList.add('dark'); try { localStorage.setItem('shelldocs-theme','dark'); } catch{}"
: "document.documentElement.classList.remove('dark'); try { localStorage.setItem('shelldocs-theme','light'); } catch{}");
if (_subscription is not null)
{
await _subscription.InvokeVoidAsync("dispose");
await _subscription.DisposeAsync();
}
}
catch { }
_ref?.Dispose();
}

public void Dispose() => Theme.OnChange -= StateHasChanged;
}
5 changes: 5 additions & 0 deletions src/ShellDocs.Components/Chrome/ThemeToggle.razor.css
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,8 @@
.theme-toggle:hover { color: var(--foreground); background: var(--muted); }
.theme-toggle:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
.theme-toggle ::deep svg { width: 1rem; height: 1rem; }

/* Both icons render; the dark class picks one, so a theme flipped by any script shows here. */
.theme-toggle ::deep .theme-icon-dark { display: none; }
:root.dark .theme-toggle ::deep .theme-icon-light { display: none; }
:root.dark .theme-toggle ::deep .theme-icon-dark { display: inline; }
Loading
Loading